import { spawnSync } from 'node:child_process'; import { createServer } from 'node:net'; import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; // Le daemon écoute sur un port FIXE (7317) : c'est ce qui rend l'URL locale mémorisable, mais aussi // ce qui rend le démarrage fragile dès qu'un autre process le tient. Ce module répond à la seule // question qui compte alors : qui l'occupe, et avons-nous le droit de le reprendre ? /** Empreinte du daemon lancé par cette app : de quoi reconnaître un orphelin au démarrage suivant. */ export interface DaemonRecord { /** pid du process Node du daemon. */ pid: number; /** pid du process Electron qui l'a lancé : s'il est mort, le daemon n'a plus de pilote. */ ownerPid: number; port: number; } export type PortConflict = /** Notre daemon, dont l'Electron parent est mort : récupérable. */ | { kind: 'orphan'; pid: number } /** Une autre instance vivante de l'app (fenêtre probablement dans le tray). */ | { kind: 'other-instance'; pid: number } /** Un tiers : service `arboretum install`, `npx @johanleroy/git-arboretum`, autre logiciel. */ | { kind: 'foreign' }; const RECLAIM_GRACE_MS = 3_000; const RECLAIM_POLL_MS = 100; /** Le port est-il libre ? Bind réel sur l'interface exacte du daemon (aucune heuristique). */ export function isPortFree(port: number, host = '127.0.0.1'): Promise { return new Promise((resolve) => { const probe = createServer(); probe.once('error', () => resolve(false)); probe.once('listening', () => probe.close(() => resolve(true))); probe.listen({ port, host, exclusive: true }); }); } /** Vivacité d'un pid. `EPERM` = process existant mais hors de notre portée, donc vivant. */ export function processAlive(pid: number): boolean { if (!Number.isInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch (err) { return (err as NodeJS.ErrnoException).code === 'EPERM'; } } export function readDaemonRecord(file: string): DaemonRecord | null { try { const raw = JSON.parse(readFileSync(file, 'utf8')) as Partial; const { pid, ownerPid, port } = raw; if (!Number.isInteger(pid) || !Number.isInteger(ownerPid) || !Number.isInteger(port)) return null; return { pid: pid as number, ownerPid: ownerPid as number, port: port as number }; } catch { return null; } } // L'empreinte est un confort de diagnostic : son écriture ne doit jamais faire échouer un démarrage. export function writeDaemonRecord(file: string, rec: DaemonRecord): void { try { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, JSON.stringify(rec), 'utf8'); } catch { /* best-effort */ } } export function clearDaemonRecord(file: string): void { try { rmSync(file, { force: true }); } catch { /* best-effort */ } } /** * Qui tient le port ? Fonction pure (vivacité injectée) : l'empreinte du dernier daemon lancé est le * seul élément qui distingue notre propre orphelin d'une autre instance ou d'un logiciel tiers. * À n'appeler que sur un port déjà constaté occupé. */ export function classifyPortConflict( record: DaemonRecord | null, alive: (pid: number) => boolean, port: number, ): PortConflict { if (!record || record.port !== port || !alive(record.pid)) return { kind: 'foreign' }; return alive(record.ownerPid) ? { kind: 'other-instance', pid: record.pid } : { kind: 'orphan', pid: record.pid }; } /** * Ligne de commande d'un pid, ou `null` si on ne peut pas la lire. Sert de preuve d'identité avant de * tuer quoi que ce soit ; l'absence de preuve vaut refus. */ export function processCommandLine(pid: number): string | null { if (!Number.isInteger(pid) || pid <= 0) return null; try { const res = process.platform === 'win32' ? spawnSync( 'powershell.exe', ['-NoProfile', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`], { encoding: 'utf8', timeout: 5_000, windowsHide: true }, ) : // -ww : sortie NON tronquée. Les chemins en jeu (node bundlé + entrée du serveur dans les // ressources de l'app) dépassent largement la largeur d'écran par défaut de ps. spawnSync('ps', ['-ww', '-o', 'command=', '-p', String(pid)], { encoding: 'utf8', timeout: 5_000 }); const out = (res.stdout ?? '').trim(); return out.length > 0 ? out : null; } catch { return null; } } /** * Le pid exécute-t-il BIEN notre daemon ? Un pidfile périmé peut désigner un pid recyclé entre-temps * par n'importe quel programme de l'utilisateur : sans cette vérification, la reprise du port se * changerait en « tuer un process innocent ». Pas de preuve lisible = pas de reprise. */ export function isOurDaemonProcess(pid: number, serverEntry: string): boolean { const cmd = processCommandLine(pid); return cmd !== null && cmd.includes(serverEntry); } /** * Termine un daemon orphelin et attend la libération EFFECTIVE du port (SIGTERM, puis SIGKILL) : * le pid disparu ne suffit pas, seul un bind réussi prouve que la voie est libre. */ export async function reclaimOrphanDaemon(pid: number, port: number, host = '127.0.0.1'): Promise { try { process.kill(pid, 'SIGTERM'); } catch { return isPortFree(port, host); } if (await waitForPortFree(port, RECLAIM_GRACE_MS, host)) return true; try { process.kill(pid, 'SIGKILL'); } catch { /* déjà parti */ } return waitForPortFree(port, RECLAIM_GRACE_MS, host); } export async function waitForPortFree(port: number, timeoutMs: number, host = '127.0.0.1'): Promise { const deadline = Date.now() + timeoutMs; for (;;) { if (await isPortFree(port, host)) return true; if (Date.now() >= deadline) return false; await sleep(RECLAIM_POLL_MS); } } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); }