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 => ({ 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 { 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 { 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((resolve) => srv.listen(port, '127.0.0.1', resolve)); expect(await isPortFree(port)).toBe(false); await new Promise((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((resolve) => srv.listen(port, '127.0.0.1', resolve)); expect(await waitForPortFree(port, 250)).toBe(false); await new Promise((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((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); });