fix(desktop): l'app démarre après une mise à jour, et parle quand elle ne peut pas
Installer une nouvelle version remplace les fichiers sur disque mais ne touche
pas le process en cours : l'ancienne instance gardait le port 7317, la version
fraîchement installée mourait sur EADDRINUSE avant son handshake, et le shell se
contentait d'un console.error suivi d'un app.quit(). Depuis le lanceur, cliquer
l'icône ne produisait donc rien du tout.
- Tout échec de démarrage ouvre un dialogue Retry / Show log / Quit
(start-failure.ts, texte pur et testé) et la sortie du daemon est conservée
dans <userData>/logs/daemon.log. Une mort du daemon APRÈS le handshake propose
de le relancer, au lieu de laisser une fenêtre morte à l'écran.
- Le port est diagnostiqué avant le spawn (port-guard.ts, empreinte
{pid, ownerPid, port}) : un daemon orphelin, dont l'Electron est mort, est
repris (SIGTERM puis SIGKILL, en attendant un bind réellement possible) ;
une instance vivante ou un tiers (service, npx) est annoncé avec l'action qui
débloque, et jamais tué. La reprise exige deux preuves, l'empreinte orpheline
ET l'identité du process (ps -ww), car un pidfile périmé peut désigner un pid
recyclé entre-temps par un programme quelconque.
- Une mise à jour installée à chaud est signalée avec « Restart now »
(upgrade-watch.ts), qui arrête le daemon avant app.relaunch() ; sans quoi le
lock d'instance unique renvoyait silencieusement sur la fenêtre de l'ancienne
version, et on croyait avoir migré.
- ARBORETUM_DESKTOP_PORT pour cohabiter avec un Arboretum qui occupe 7317 en
permanence (service installé, ou daemon lancé en terminal).
24 tests dans packages/desktop/test, et quatre scénarios rejoués en dev sous
xvfb-run avec profil isolé : port tenu par un tiers, orphelin repris puis SPA
servie, instance vivante laissée intacte, pid recyclé épargné.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createServer } from 'node:net';
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
classifyPortConflict,
|
||||
clearDaemonRecord,
|
||||
isOurDaemonProcess,
|
||||
isPortFree,
|
||||
processAlive,
|
||||
readDaemonRecord,
|
||||
reclaimOrphanDaemon,
|
||||
waitForPortFree,
|
||||
writeDaemonRecord,
|
||||
type DaemonRecord,
|
||||
} from '../src/main/port-guard';
|
||||
|
||||
const rec = (over: Partial<DaemonRecord> = {}): DaemonRecord => ({ pid: 111, ownerPid: 222, port: 7317, ...over });
|
||||
|
||||
/**
|
||||
* Réserve un port libre HORS de la plage éphémère du noyau (32768+). Un `listen(0)` rendu puis
|
||||
* réutilisé peut être réattribué entre-temps à un autre worker vitest : le test échouait alors une
|
||||
* fois sur N. Ici le port est choisi dans une plage que personne n'obtient par tirage.
|
||||
*/
|
||||
async function reservePort(): Promise<number> {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const port = 7400 + Math.floor(Math.random() * 600);
|
||||
if (await isPortFree(port)) return port;
|
||||
}
|
||||
throw new Error('aucun port libre dans 7400-7999');
|
||||
}
|
||||
|
||||
/** Attend que le squatteur ait RÉELLEMENT bind (un spawn met quelques dizaines de ms à écouter). */
|
||||
async function waitUntilBusy(port: number, timeoutMs = 5_000): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (!(await isPortFree(port))) return true;
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
describe('classifyPortConflict', () => {
|
||||
it('sans empreinte, le port est tenu par un tiers', () => {
|
||||
expect(classifyPortConflict(null, () => true, 7317)).toEqual({ kind: 'foreign' });
|
||||
});
|
||||
|
||||
it('empreinte sur un AUTRE port : sans rapport avec le conflit courant', () => {
|
||||
expect(classifyPortConflict(rec({ port: 7400 }), () => true, 7317)).toEqual({ kind: 'foreign' });
|
||||
});
|
||||
|
||||
it('daemon de l’empreinte mort : le port est tenu par autre chose', () => {
|
||||
expect(classifyPortConflict(rec(), () => false, 7317)).toEqual({ kind: 'foreign' });
|
||||
});
|
||||
|
||||
it('daemon vivant + Electron parent vivant : autre instance de l’app', () => {
|
||||
expect(classifyPortConflict(rec(), () => true, 7317)).toEqual({ kind: 'other-instance', pid: 111 });
|
||||
});
|
||||
|
||||
it('daemon vivant mais Electron parent mort : orphelin récupérable', () => {
|
||||
const alive = (pid: number): boolean => pid === 111;
|
||||
expect(classifyPortConflict(rec(), alive, 7317)).toEqual({ kind: 'orphan', pid: 111 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('empreinte du daemon', () => {
|
||||
it('écrit, relit et efface', () => {
|
||||
const file = join(mkdtempSync(join(tmpdir(), 'arb-pidfile-')), 'daemon.json');
|
||||
writeDaemonRecord(file, rec());
|
||||
expect(readDaemonRecord(file)).toEqual(rec());
|
||||
clearDaemonRecord(file);
|
||||
expect(readDaemonRecord(file)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejette un contenu illisible ou incomplet plutôt que de deviner', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'arb-pidfile-'));
|
||||
const bad = join(dir, 'bad.json');
|
||||
writeFileSync(bad, 'pas du json', 'utf8');
|
||||
expect(readDaemonRecord(bad)).toBeNull();
|
||||
const partial = join(dir, 'partial.json');
|
||||
writeFileSync(partial, JSON.stringify({ pid: 12 }), 'utf8');
|
||||
expect(readDaemonRecord(partial)).toBeNull();
|
||||
});
|
||||
|
||||
it('n’échoue pas si le chemin est inécrivable (diagnostic best-effort)', () => {
|
||||
// Un fichier régulier en guise de répertoire parent : mkdir/write échouent (ENOTDIR) et
|
||||
// l'écriture de l'empreinte doit rester silencieuse, jamais bloquer un démarrage.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'arb-pidfile-'));
|
||||
const blocker = join(dir, 'pas-un-dossier');
|
||||
writeFileSync(blocker, 'x', 'utf8');
|
||||
expect(() => writeDaemonRecord(join(blocker, 'daemon.json'), rec())).not.toThrow();
|
||||
expect(readDaemonRecord(join(blocker, 'daemon.json'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('processAlive', () => {
|
||||
it('reconnaît le process courant et refuse les pid invalides', () => {
|
||||
expect(processAlive(process.pid)).toBe(true);
|
||||
expect(processAlive(0)).toBe(false);
|
||||
expect(processAlive(-1)).toBe(false);
|
||||
expect(processAlive(Number.NaN)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPortFree', () => {
|
||||
it('distingue un port libre d’un port occupé', async () => {
|
||||
const port = await reservePort();
|
||||
expect(await isPortFree(port)).toBe(true);
|
||||
const srv = createServer();
|
||||
await new Promise<void>((resolve) => srv.listen(port, '127.0.0.1', resolve));
|
||||
expect(await isPortFree(port)).toBe(false);
|
||||
await new Promise<void>((resolve) => srv.close(() => resolve()));
|
||||
expect(await isPortFree(port)).toBe(true);
|
||||
});
|
||||
|
||||
it('waitForPortFree rend la main sur expiration sans boucler indéfiniment', async () => {
|
||||
const port = await reservePort();
|
||||
const srv = createServer();
|
||||
await new Promise<void>((resolve) => srv.listen(port, '127.0.0.1', resolve));
|
||||
expect(await waitForPortFree(port, 250)).toBe(false);
|
||||
await new Promise<void>((resolve) => srv.close(() => resolve()));
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOurDaemonProcess', () => {
|
||||
it('reconnaît un process dont la commande porte l’entrée du serveur', async () => {
|
||||
const marker = join(mkdtempSync(join(tmpdir(), 'arb-entry-')), 'server-entry-marker.js');
|
||||
writeFileSync(marker, 'setInterval(()=>{},1000)', 'utf8');
|
||||
const child = spawn(process.execPath, [marker]);
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
expect(isOurDaemonProcess(child.pid as number, marker)).toBe(true);
|
||||
// Un pid recyclé par un programme quelconque ne doit PAS passer pour notre daemon.
|
||||
expect(isOurDaemonProcess(child.pid as number, '/opt/ailleurs/dist/index.js')).toBe(false);
|
||||
child.kill('SIGKILL');
|
||||
});
|
||||
|
||||
it('refuse quand la commande est illisible (pid absent, pid invalide)', () => {
|
||||
expect(isOurDaemonProcess(2_147_483_600, '/quelconque')).toBe(false);
|
||||
expect(isOurDaemonProcess(0, '/quelconque')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reclaimOrphanDaemon', () => {
|
||||
it('termine le squatteur et attend la libération EFFECTIVE du port', async () => {
|
||||
const port = await reservePort();
|
||||
const child = spawn(process.execPath, [
|
||||
'-e',
|
||||
`require('net').createServer().listen(${port},'127.0.0.1');setInterval(()=>{},1000)`,
|
||||
]);
|
||||
expect(await waitUntilBusy(port)).toBe(true); // l'enfant a bien pris le port
|
||||
expect(await reclaimOrphanDaemon(child.pid as number, port)).toBe(true);
|
||||
expect(await isPortFree(port)).toBe(true);
|
||||
}, 12_000);
|
||||
|
||||
it('un pid déjà mort ne bloque rien', async () => {
|
||||
const port = await reservePort();
|
||||
const child = spawn(process.execPath, ['-e', 'process.exit(0)']);
|
||||
await new Promise<void>((resolve) => child.once('exit', () => resolve()));
|
||||
expect(await reclaimOrphanDaemon(child.pid as number, port)).toBe(true);
|
||||
});
|
||||
|
||||
it('SIGKILL en dernier recours quand SIGTERM est ignoré', async () => {
|
||||
const port = await reservePort();
|
||||
const child = spawn(process.execPath, [
|
||||
'-e',
|
||||
`process.on('SIGTERM',()=>{});require('net').createServer().listen(${port},'127.0.0.1');setInterval(()=>{},1000)`,
|
||||
]);
|
||||
expect(await waitUntilBusy(port)).toBe(true);
|
||||
expect(await reclaimOrphanDaemon(child.pid as number, port)).toBe(true);
|
||||
expect(await isPortFree(port)).toBe(true);
|
||||
}, 12_000);
|
||||
});
|
||||
Reference in New Issue
Block a user