Files
arboretum/packages/desktop/test/port-guard.test.ts
T
johanleroy 9390b62249 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é.
2026-08-05 09:10:43 +02:00

175 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});