fix : Firefox handling 2
All checks were successful
release / build (push) Successful in 24s
release / verify-windows (push) Successful in 1m13s

This commit is contained in:
jeanotx32
2026-08-12 02:45:09 +02:00
parent dcbc846ae7
commit 907a502f26
2 changed files with 189 additions and 18 deletions

View File

@@ -222,7 +222,31 @@ qu'une sortie non nulle comme un échec ; sinon il abandonnerait le cas nominal
quelques centaines de millisecondes. quelques centaines de millisecondes.
La sortie de Firefox est redirigée vers `<profil>/firefox.log`, et ses dernières lignes La sortie de Firefox est redirigée vers `<profil>/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/<pid>/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 **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 « capture de fenêtre » d'OBS dessus une fois. Ensuite elle survit aux enregistrements

View File

@@ -8,6 +8,15 @@ import { assertWebUrl, type BrowserLog } from './browser.ts';
import type { FullscreenOutcome } from './fullscreen.ts'; import type { FullscreenOutcome } from './fullscreen.ts';
import { sessionEnv } from './x11.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. * Firefox piloté par l'agent, via WebDriver BiDi.
* *
@@ -271,6 +280,12 @@ export class FirefoxController {
const blocked = profileRefusedBySnap(this.settings.command, profile); const blocked = profileRefusedBySnap(this.settings.command, profile);
if (blocked) throw new Error(blocked); 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); writeProfilePrefs(profile);
const args = [ const args = [
@@ -317,21 +332,38 @@ export class FirefoxController {
// passe la main au snap et rend aussitôt la main. Le vrai navigateur // passe la main au snap et rend aussitôt la main. Le vrai navigateur
// démarre derrière — abandonner ici tuerait le cas nominal. // démarre derrière — abandonner ici tuerait le cas nominal.
if (code !== 0 && !this.client?.isOpen) { if (code !== 0 && !this.client?.isOpen) {
// Sans le journal : il est ajouté une seule fois, au rattrapage.
fatal = fatal =
`Firefox s'est arrêté (code ${code}) sans ouvrir le port de pilotage. ` + `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 ? ` + `Le profil ${profile} est-il déjà ouvert dans une autre instance ?`;
tail(logPath);
} }
}); });
child.unref(); child.unref();
const client = await BidiClient.open(this.settings.remotePort, 45_000, () => fatal).catch( // Le premier démarrage d'un snap sur une VM dépasse couramment la minute :
(err: Error) => { // décompression, mise en place du confinement, profil neuf à construire.
this.lastError = `${err.message} ${tail(logPath)}`.trim(); // Un délai de 45 s abandonnait un navigateur qui était simplement lent.
throw new Error(this.lastError); 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`);
await this.adopt(client); }, 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. */ /** É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<void> {
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 <chemin>`).
*
* Réservé à Linux, où `/proc/<pid>/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<number> {
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 --------------------------------------------------------- // --- Confinement snap ---------------------------------------------------------
/** /**
@@ -530,8 +654,22 @@ function locate(command: string): string | null {
return 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; let content: string;
try { try {
content = fs.readFileSync(logPath, 'utf8'); content = fs.readFileSync(logPath, 'utf8');
@@ -539,12 +677,21 @@ function tail(logPath: string, lines = 4): string {
return ''; return '';
} }
const kept = content const all = content.split('\n').map((line) => line.trim()).filter(Boolean);
.split('\n') const useful = all.filter((line) => !SNAPD_NOISE.some((pattern) => pattern.test(line)));
.map((line) => line.trim()) const dropped = all.length - useful.length;
.filter(Boolean) const kept = useful.slice(-lines);
.slice(-lines);
return kept.length ? `Firefox a écrit : ${kept.join(' / ')}` : ''; 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é ------------------------------------------------------------- // --- Profil dédié -------------------------------------------------------------