fix : Firefox handling 2
This commit is contained in:
26
README.md
26
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 `<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
|
||||
« capture de fenêtre » d'OBS dessus une fois. Ensuite elle survit aux enregistrements
|
||||
|
||||
@@ -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<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 ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -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é -------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user