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:
2026-08-05 09:10:43 +02:00
parent c6deded0c6
commit 9390b62249
12 changed files with 882 additions and 33 deletions
+20
View File
@@ -4,6 +4,26 @@ Notable changes to the Arboretum desktop app (`packages/desktop`). The daemon an
extension keep their own changelogs in `packages/server/CHANGELOG.md` and
`packages/vscode/CHANGELOG.md`.
## Unreleased
- **The app could refuse to start after an update, silently.** Installing a new version replaces the
files on disk but leaves the running app alone: its daemon kept port 7317, so the version you just
installed hit `EADDRINUSE`, its daemon died before the handshake, and the shell logged the failure to a
console nobody sees and quit. Clicking the launcher appeared to do nothing at all. Three fixes:
- **Every start-up failure now opens a dialog** with *Retry / Show log / Quit* instead of vanishing,
and the daemon's output is kept in `<userData>/logs/daemon.log`. A daemon that dies *after* start-up
is reported too, with an offer to restart it, instead of leaving a dead window on screen.
- **A busy port is diagnosed, not just fatal** (`src/main/port-guard.ts`). The daemon we spawn is
recorded in `<userData>/daemon/daemon.json`, so an *orphaned* daemon (its Electron gone after a
crash, a `kill -9` or an upgrade) is reclaimed - SIGTERM then SIGKILL, waiting for the port to be
effectively free - while a live sibling instance or third-party server is reported with the action
that unblocks it, and never killed.
- **An update installed while the app runs is announced** (`src/main/upgrade-watch.ts`). Until now the
single-instance lock quietly routed you back to the old version's window; the shell now notices its
own binary changed on disk and offers *Restart now*, which stops the daemon before relaunching.
- **`ARBORETUM_DESKTOP_PORT`** picks another port, for machines where a service or terminal daemon owns
7317 permanently.
## 0.2.3
Ships the daemon 3.6.0. Files open again (the editor area could stay blank), and uncommitted work
+38
View File
@@ -91,6 +91,44 @@ then "Open", or run `xattr -dr com.apple.quarantine /Applications/Arboretum.app`
`/usr/local/bin`, `/opt/homebrew/bin`, `~/.local/bin`; on Windows `%LOCALAPPDATA%\Programs` and
`%APPDATA%\npm`, where the Claude CLI and global npm binaries actually live.
## Startup, and what happens when it fails
The shell owns the daemon: it spawns it on **port 7317** (`ARBORETUM_DESKTOP_PORT` overrides), waits for
the handshake on fd 3, seeds the session cookie, then loads the SPA. Since a fixed port is easy to hold
hostage, the port is checked *before* spawning (`src/main/port-guard.ts`) and the outcome decides:
| Who holds the port | What the app does |
| --- | --- |
| Nobody | Starts normally. |
| **Our own daemon, orphaned** (its Electron died: crash, `kill -9`, package upgrade) | Reclaims it: SIGTERM, then SIGKILL, waiting for the port to be *effectively* free, then starts. |
| **Another live instance** of the app | Says so, and points at the tray where that window is hiding. Never kills it. |
| A third party (`arboretum install` service, `npx @johanleroy/git-arboretum`, unrelated software) | Says so, and suggests stopping it or setting `ARBORETUM_DESKTOP_PORT`. |
Ownership is recorded in `<userData>/daemon/daemon.json` (`{pid, ownerPid, port}`): a live daemon whose
`ownerPid` is gone is an orphan, one whose owner is alive is another instance. Every failure now opens a
dialog with **Retry / Show log / Quit** instead of quitting silently, and the daemon's output is kept in
`<userData>/logs/daemon.log`. If the daemon dies *after* startup, the app offers to restart it rather
than leaving a dead window on screen.
`<userData>` is `~/.config/Arboretum` (Linux), `~/Library/Application Support/Arboretum` (macOS),
`%APPDATA%\Arboretum` (Windows).
## Installing a new version
Installers replace the files on disk; they never touch the running process. So after a `dpkg -i` (or an
NSIS run) **the open window keeps serving the old version**, and its daemon keeps port 7317 - which used
to make the freshly installed version unable to start at all.
The recommended order is therefore either one of:
1. Quit Arboretum from the tray, then install, then launch. Clean, nothing to think about.
2. Install while it runs, then click the launcher or the tray icon: the shell notices that its own
binary changed on disk (`src/main/upgrade-watch.ts`) and offers **Restart now**, which stops the
daemon before relaunching, so the new version finds its port free.
Answering *Later* keeps the old window; the prompt comes back only if yet another version is installed.
The check is inert in dev (`app.isPackaged` is false).
## Auto-update
electron-builder emits `latest*.yml` next to the artifacts and `electron-updater` reads them from a
+121 -20
View File
@@ -1,11 +1,31 @@
import { spawn, type ChildProcess } from 'node:child_process';
import { join } from 'node:path';
import { createWriteStream, mkdirSync, statSync, truncateSync, type WriteStream } from 'node:fs';
import { dirname, join } from 'node:path';
import { resolveNodeBin, resolveServerEntry } from './paths';
import { buildChildEnv } from './env';
import {
classifyPortConflict,
clearDaemonRecord,
isOurDaemonProcess,
isPortFree,
processAlive,
readDaemonRecord,
reclaimOrphanDaemon,
writeDaemonRecord,
} from './port-guard';
import { DaemonStartError } from './start-failure';
const HANDSHAKE_TIMEOUT_MS = 30_000;
/** Lignes de sortie conservées pour le diagnostic affiché en cas d'échec. */
const LOG_TAIL_LINES = 40;
/** Au-delà, le journal est tronqué au démarrage (fichier de dépannage, pas d'archive). */
const MAX_LOG_BYTES = 2_000_000;
export interface DaemonHandle {
url: string;
token: string;
/** Dernières lignes de sortie du daemon (diagnostic). */
logTail(): string;
stop(): Promise<void>;
}
@@ -14,19 +34,31 @@ interface Handshake {
url: string;
}
export interface StartDaemonOptions {
dataDir: string;
port: number;
/** Empreinte du daemon lancé, pour récupérer un orphelin au démarrage suivant. */
pidfile?: string;
/** Journal persistant du daemon (dépannage hors terminal). */
logFile?: string;
onLog?: (line: string) => void;
/** Appelé si le daemon s'arrête APRÈS le handshake (mort inattendue). */
onExit?: (code: number | null) => void;
}
/**
* Lance le daemon en process enfant (Node bundlé) et attend son handshake sur le fd 3
* (ARBORETUM_EMIT_TOKEN_FD=3 côté serveur) : la réception du JSON {token,url} prouve que le
* serveur écoute (le handshake est écrit après app.listen). Arrêt propre : SIGTERM puis SIGKILL.
*
* Le port est vérifié AVANT le spawn : sans ça, un daemon resté seul après un crash ou une mise à
* jour rendait l'app définitivement inutilisable (EADDRINUSE, enfant mort, aucun message).
*/
export function startDaemon(opts: {
dataDir: string;
port: number;
onLog?: (line: string) => void;
onExit?: (code: number | null) => void;
}): Promise<DaemonHandle> {
export async function startDaemon(opts: StartDaemonOptions): Promise<DaemonHandle> {
const node = resolveNodeBin();
const entry = resolveServerEntry();
await ensurePortAvailable(opts.port, entry, opts.pidfile);
const dbPath = join(opts.dataDir, 'arboretum.db');
const env = buildChildEnv({ XDG_DATA_HOME: opts.dataDir, ARBORETUM_EMIT_TOKEN_FD: '3' });
@@ -35,18 +67,41 @@ export function startDaemon(opts: {
stdio: ['ignore', 'pipe', 'pipe', 'pipe'],
});
child.stdout?.on('data', (d: Buffer) => opts.onLog?.(d.toString()));
child.stderr?.on('data', (d: Buffer) => opts.onLog?.(d.toString()));
const logSink = opts.logFile ? openLogFile(opts.logFile) : null;
const tail: string[] = [];
const collect = (chunk: Buffer): void => {
const text = chunk.toString();
opts.onLog?.(text);
logSink?.write(text);
for (const line of text.split('\n')) {
if (!line.trim()) continue;
tail.push(line);
if (tail.length > LOG_TAIL_LINES) tail.shift();
}
};
const logTail = (): string => tail.join('\n');
child.stdout?.on('data', collect);
child.stderr?.on('data', collect);
if (child.pid !== undefined && opts.pidfile) {
writeDaemonRecord(opts.pidfile, { pid: child.pid, ownerPid: process.pid, port: opts.port });
}
let stopped = false;
const stop = (): Promise<void> =>
new Promise((resolve) => {
if (stopped || child.exitCode !== null) return resolve();
const done = (): void => {
if (opts.pidfile) clearDaemonRecord(opts.pidfile);
logSink?.end();
resolve();
};
if (stopped || child.exitCode !== null) return done();
stopped = true;
const killTimer = setTimeout(() => child.kill('SIGKILL'), 3000);
child.once('exit', () => {
clearTimeout(killTimer);
resolve();
done();
});
child.kill('SIGTERM');
});
@@ -59,16 +114,28 @@ export function startDaemon(opts: {
if (settled) return;
settled = true;
void stop();
reject(new Error('daemon handshake timeout'));
}, 30000);
reject(new DaemonStartError('handshake', 'daemon handshake timeout', logTail()));
}, HANDSHAKE_TIMEOUT_MS);
child.once('exit', (code) => {
opts.onExit?.(code);
if (!settled) {
settled = true;
clearTimeout(timer);
reject(new Error(`daemon exited before handshake (code ${code ?? 'null'})`));
if (settled) {
// Mort après le handshake : l'empreinte ne décrit plus rien de vivant.
if (opts.pidfile) clearDaemonRecord(opts.pidfile);
opts.onExit?.(code);
return;
}
settled = true;
clearTimeout(timer);
if (opts.pidfile) clearDaemonRecord(opts.pidfile);
logSink?.end();
// Course perdue entre la vérification du port et le bind du daemon : le motif reste « port pris »,
// pas un échec de handshake opaque.
const busy = /EADDRINUSE/.test(logTail());
reject(
busy
? new DaemonStartError('port-busy-foreign', `port ${opts.port} is already in use`, logTail())
: new DaemonStartError('handshake', `daemon exited before handshake (code ${code ?? 'null'})`, logTail()),
);
});
fd3?.on('data', (chunk: Buffer) => {
@@ -80,11 +147,45 @@ export function startDaemon(opts: {
clearTimeout(timer);
try {
const hs = JSON.parse(buf.slice(0, nl)) as Handshake;
resolve({ url: hs.url, token: hs.token, stop });
resolve({ url: hs.url, token: hs.token, logTail, stop });
} catch (err) {
void stop();
reject(err instanceof Error ? err : new Error(String(err)));
reject(new DaemonStartError('handshake', err instanceof Error ? err.message : String(err), logTail()));
}
});
});
}
/**
* Libère le port si l'occupant est un daemon à nous devenu orphelin ; sinon échoue avec un motif que
* le dialogue sait traduire en action (autre instance dans le tray, service, daemon en terminal).
*/
async function ensurePortAvailable(port: number, serverEntry: string, pidfile?: string): Promise<void> {
if (await isPortFree(port)) return;
const conflict = classifyPortConflict(pidfile ? readDaemonRecord(pidfile) : null, processAlive, port);
// La reprise exige DEUX preuves : l'empreinte désigne un daemon sans pilote, et le pid exécute
// effectivement notre serveur (un pid recyclé ne doit jamais être tué à sa place).
if (conflict.kind === 'orphan' && isOurDaemonProcess(conflict.pid, serverEntry)) {
// Daemon survivant à son Electron (crash, kill -9, paquet mis à jour) : plus personne ne le
// pilote et il tient la base ET le port. On le récupère au lieu de condamner l'app.
await reclaimOrphanDaemon(conflict.pid, port);
if (await isPortFree(port)) return;
}
throw new DaemonStartError(
conflict.kind === 'other-instance' ? 'port-busy-instance' : 'port-busy-foreign',
`port ${port} is already in use`,
);
}
function openLogFile(path: string): WriteStream | null {
try {
mkdirSync(dirname(path), { recursive: true });
const size = statSync(path, { throwIfNoEntry: false })?.size ?? 0;
if (size > MAX_LOG_BYTES) truncateSync(path, 0);
return createWriteStream(path, { flags: 'a' });
} catch {
return null; // un journal indisponible ne doit pas empêcher le démarrage
}
}
+207 -13
View File
@@ -1,4 +1,13 @@
import { app, BrowserWindow, session, shell, type BrowserWindowConstructorOptions, type Tray } from 'electron';
import {
app,
BrowserWindow,
dialog,
session,
shell,
type BrowserWindowConstructorOptions,
type Tray,
} from 'electron';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { startDaemon, type DaemonHandle } from './daemon';
import { seedSessionCookie } from './auth';
@@ -8,6 +17,8 @@ import { installAppMenu } from './app-menu';
import { registerClipboardBridge } from './clipboard';
import { initUpdater } from './updater';
import { resolveIconPath } from './paths';
import { describeStartFailure } from './start-failure';
import { installChanged, readInstallStamp, type InstallStamp } from './upgrade-watch';
// WM_CLASS / app_id déterministe, posé AVANT app.whenReady(). Sous Wayland (défaut Debian/GNOME)
// l'option `icon:` de BrowserWindow est ignorée : l'icône de fenêtre/dock vient du fichier .desktop
@@ -17,47 +28,226 @@ import { resolveIconPath } from './paths';
app.setName('Arboretum');
const PARTITION = 'persist:arboretum';
const PORT = 7317;
const DEFAULT_PORT = 7317;
const PORT = resolvePort();
let daemon: DaemonHandle | null = null;
let win: BrowserWindow | null = null;
let tray: Tray | null = null;
let isQuitting = false;
let shuttingDown = false;
let relaunchAfterQuit = false;
let bridgeRegistered = false;
/** Le daemon a passé son handshake : sa mort devient un incident à signaler. */
let serverReady = false;
/** Empreinte du binaire au lancement, comparée plus tard pour repérer une mise à jour installée. */
const bootStamp = readInstallStamp(process.execPath);
let dismissedStamp: InstallStamp | null = null;
let restartPromptOpen = false;
// Instance unique : deux instances = deux daemons/ports en conflit.
if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
app.on('second-instance', showWindow);
app.whenReady().then(bootstrap).catch((err: unknown) => {
console.error('[arboretum-desktop] bootstrap failed:', err);
app.quit();
});
void app.whenReady()
.then(startWithRetry)
.catch((err: unknown) => {
console.error('[arboretum-desktop] fatal:', err);
app.exit(1);
});
}
/**
* Port du daemon local. Surcharge par variable d'env pour cohabiter avec un Arboretum déjà installé
* en service (ou lancé en terminal) qui tient 7317 en permanence.
*/
function resolvePort(): number {
const n = Number(process.env.ARBORETUM_DESKTOP_PORT);
return Number.isInteger(n) && n >= 1024 && n <= 65535 ? n : DEFAULT_PORT;
}
function logFilePath(): string {
return join(app.getPath('userData'), 'logs', 'daemon.log');
}
/**
* Démarre l'app, et en cas d'échec propose une action au lieu de disparaître : un « rien ne se passe »
* au clic sur l'icône était le pire symptôme possible (port occupé, Node absent, base verrouillée).
*/
async function startWithRetry(): Promise<void> {
for (;;) {
try {
await bootstrap();
return;
} catch (err) {
console.error('[arboretum-desktop] bootstrap failed:', err);
await stopDaemonQuietly();
if ((await promptStartFailure(err)) === 'quit') {
isQuitting = true;
app.exit(1);
return;
}
}
}
}
async function bootstrap(): Promise<void> {
const dataDir = join(app.getPath('userData'), 'daemon');
daemon = await startDaemon({ dataDir, port: PORT, onLog: (l) => process.stdout.write(l) });
await seedSessionCookie(PARTITION, daemon.url, daemon.token);
registerClipboardBridge();
createWindow(daemon.url);
installAppMenu({ url: daemon.url, onQuit: quitApp });
tray = createTray({ show: showWindow, quit: quitApp });
await startServer();
// Idempotent : un « Retry » après échec ne doit pas réenregistrer le pont IPC ni empiler un tray.
if (!bridgeRegistered) {
registerClipboardBridge();
bridgeRegistered = true;
}
if (!win) createWindow(daemonUrl());
installAppMenu({ url: daemonUrl(), onQuit: quitApp });
if (!tray) tray = createTray({ show: showWindow, quit: quitApp });
initUpdater();
}
/** Daemon + cookie de session : le strict nécessaire pour charger la SPA (aussi utilisé au redémarrage). */
async function startServer(): Promise<void> {
const dataDir = join(app.getPath('userData'), 'daemon');
daemon = await startDaemon({
dataDir,
port: PORT,
pidfile: join(dataDir, 'daemon.json'),
logFile: logFilePath(),
onLog: (l) => process.stdout.write(l),
onExit: handleDaemonExit,
});
serverReady = true;
await seedSessionCookie(PARTITION, daemon.url, daemon.token);
}
function daemonUrl(): string {
return daemon?.url ?? `http://127.0.0.1:${PORT}`;
}
async function stopDaemonQuietly(): Promise<void> {
serverReady = false;
const handle = daemon;
daemon = null;
await handle?.stop();
}
/** Dialogue d'échec : motif traduit en action, avec accès au journal du daemon. */
async function promptStartFailure(err: unknown): Promise<'retry' | 'quit'> {
const { message, detail } = describeStartFailure(err, PORT);
const log = logFilePath();
for (;;) {
const buttons = existsSync(log) ? ['Retry', 'Show log', 'Quit'] : ['Retry', 'Quit'];
const { response } = await dialog.showMessageBox({
type: 'error',
title: 'Arboretum',
message,
detail,
buttons,
defaultId: 0,
cancelId: buttons.length - 1,
noLink: true,
});
if (buttons[response] === 'Show log') {
void shell.openPath(log);
continue;
}
return buttons[response] === 'Retry' ? 'retry' : 'quit';
}
}
/**
* Mort inattendue du daemon : la fenêtre resterait affichée sur une SPA qui ne répond plus. On le dit
* et on propose de le relancer (le token change, donc cookie re-semé et fenêtre rechargée).
*/
function handleDaemonExit(code: number | null): void {
if (!serverReady || isQuitting || shuttingDown) return;
serverReady = false;
const tail = daemon?.logTail() ?? '';
daemon = null;
void promptServerStopped(code, tail);
}
async function promptServerStopped(code: number | null, tail: string): Promise<void> {
const { response } = await dialog.showMessageBox({
type: 'error',
title: 'Arboretum',
message: 'The Arboretum server stopped',
detail: [`The local server exited (code ${code ?? 'null'}).`, tail && `Server output:\n${tail}`]
.filter(Boolean)
.join('\n\n'),
buttons: ['Restart server', 'Quit'],
defaultId: 0,
cancelId: 1,
noLink: true,
});
if (response !== 0) {
quitApp();
return;
}
for (;;) {
try {
await startServer();
await win?.loadURL(`${daemonUrl()}/`);
return;
} catch (err) {
await stopDaemonQuietly();
if ((await promptStartFailure(err)) === 'quit') {
quitApp();
return;
}
}
}
}
// macOS : la fenêtre est cachée (pas détruite) à la fermeture. Sans ce handler, cliquer l'icône du
// Dock ne la ramenait jamais et l'app paraissait bloquée en arrière-plan.
app.on('activate', showWindow);
function showWindow(): void {
// Tray, second-instance et Dock passent tous ici : c'est le moment où l'utilisateur redemande
// l'app, donc le bon moment pour signaler une mise à jour installée entre-temps.
void maybePromptRestartAfterUpgrade();
if (!win) return;
if (win.isMinimized()) win.restore();
win.show();
win.focus();
}
/**
* Mise à jour installée pendant que l'app tournait : le lock d'instance unique renvoie les lancements
* suivants sur la fenêtre de l'ANCIENNE version, sans un mot, et l'utilisateur croit avoir migré.
*/
async function maybePromptRestartAfterUpgrade(): Promise<void> {
if (restartPromptOpen || isQuitting || !app.isPackaged) return;
const current = readInstallStamp(process.execPath);
if (!installChanged(bootStamp, current)) return;
if (dismissedStamp && !installChanged(dismissedStamp, current)) return;
restartPromptOpen = true;
try {
const { response } = await dialog.showMessageBox({
type: 'info',
title: 'Arboretum',
message: 'A new version of Arboretum has been installed',
detail:
`This window still runs version ${app.getVersion()}, started before the update. ` +
'Restart to load the installed version. Running sessions will be stopped.',
buttons: ['Restart now', 'Later'],
defaultId: 0,
cancelId: 1,
noLink: true,
});
if (response === 0) {
relaunchAfterQuit = true;
quitApp();
} else {
dismissedStamp = current;
}
} finally {
restartPromptOpen = false;
}
}
function quitApp(): void {
isQuitting = true;
app.quit();
@@ -138,8 +328,12 @@ async function shutdown(): Promise<void> {
await daemon?.stop();
} finally {
daemon = null;
serverReady = false;
tray?.destroy();
tray = null;
// Relance demandée après une mise à jour : l'enregistrer une fois le daemon arrêté, sinon le
// nouveau process retrouverait le port occupé par l'ancien.
if (relaunchAfterQuit) app.relaunch();
app.quit();
}
}
+158
View File
@@ -0,0 +1,158 @@
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<boolean> {
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<DaemonRecord>;
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<boolean> {
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<boolean> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -0,0 +1,62 @@
// Un démarrage raté doit se VOIR. Avant, l'échec du bootstrap se résumait à un console.error suivi
// d'un app.quit() : depuis le lanceur du bureau, l'utilisateur cliquait et « rien ne se passait ».
export type DaemonStartFailureKind = 'port-busy-instance' | 'port-busy-foreign' | 'handshake';
/** Échec de démarrage du daemon, porteur d'un motif exploitable par le dialogue utilisateur. */
export class DaemonStartError extends Error {
constructor(
readonly kind: DaemonStartFailureKind,
message: string,
readonly logTail = '',
) {
super(message);
this.name = 'DaemonStartError';
}
}
export interface StartFailureText {
message: string;
detail: string;
}
/**
* Texte du dialogue d'échec (en anglais : convention des messages utilisateur). Chaque motif porte
* l'action concrète qui débloque, jamais la seule trace technique.
*/
export function describeStartFailure(err: unknown, port: number): StartFailureText {
const kind = err instanceof DaemonStartError ? err.kind : 'handshake';
const tail = err instanceof DaemonStartError ? err.logTail : '';
const raw = err instanceof Error ? err.message : String(err);
if (kind === 'port-busy-instance') {
return {
message: 'Arboretum is already running',
detail:
`Another Arboretum instance is using port ${port}. Its window is probably hidden: ` +
'click the Arboretum icon in the system tray to bring it back, or quit it from there and retry. ' +
'If you just installed an update, quitting and retrying loads the new version.',
};
}
if (kind === 'port-busy-foreign') {
return {
message: `Port ${port} is already in use`,
detail: join([
`Another program is listening on 127.0.0.1:${port}, typically an Arboretum daemon started ` +
'from a terminal (npx @johanleroy/git-arboretum) or installed as a service (arboretum install).',
`Stop it and retry, or set ARBORETUM_DESKTOP_PORT to a free port before launching the app.`,
tail && `Server output:\n${tail}`,
]),
};
}
return {
message: 'Arboretum could not start its local server',
detail: join([raw, tail && `Server output:\n${tail}`]),
};
}
function join(parts: (string | false)[]): string {
return parts.filter((p): p is string => typeof p === 'string' && p.length > 0).join('\n\n');
}
@@ -0,0 +1,28 @@
import { statSync } from 'node:fs';
// Une mise à jour installée pendant que l'app tourne (dpkg -i, installeur nsis, .app remplacée)
// remplace le binaire sur disque sans toucher au process en cours. Le lock d'instance unique renvoie
// alors les lancements suivants vers la fenêtre de l'ANCIENNE version, silencieusement : l'utilisateur
// croit utiliser la nouvelle. Comparer une empreinte du binaire suffit à le détecter.
/** Empreinte du binaire installé. Un remplacement de fichier change l'inode (et souvent mtime/taille). */
export interface InstallStamp {
ino: number;
mtimeMs: number;
size: number;
}
export function readInstallStamp(path: string): InstallStamp | null {
try {
const st = statSync(path);
return { ino: Number(st.ino), mtimeMs: Math.floor(st.mtimeMs), size: st.size };
} catch {
return null;
}
}
/** L'installation a-t-elle changé sous nos pieds ? Une empreinte illisible ne conclut rien. */
export function installChanged(boot: InstallStamp | null, current: InstallStamp | null): boolean {
if (!boot || !current) return false;
return boot.ino !== current.ino || boot.mtimeMs !== current.mtimeMs || boot.size !== current.size;
}
+174
View File
@@ -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);
});
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { DaemonStartError, describeStartFailure } from '../src/main/start-failure';
describe('describeStartFailure', () => {
it('port tenu par une autre instance : renvoie vers le tray, pas vers une trace technique', () => {
const err = new DaemonStartError('port-busy-instance', 'port 7317 is already in use');
const { message, detail } = describeStartFailure(err, 7317);
expect(message).toBe('Arboretum is already running');
expect(detail).toContain('7317');
expect(detail).toContain('system tray');
});
it('port tenu par un tiers : nomme les suspects et l’échappatoire (variable d’env)', () => {
const err = new DaemonStartError('port-busy-foreign', 'port 7317 is already in use', 'EADDRINUSE');
const { message, detail } = describeStartFailure(err, 7317);
expect(message).toBe('Port 7317 is already in use');
expect(detail).toContain('arboretum install');
expect(detail).toContain('ARBORETUM_DESKTOP_PORT');
expect(detail).toContain('EADDRINUSE');
});
it('échec de handshake : conserve le message et la queue de journal', () => {
const err = new DaemonStartError('handshake', 'daemon exited before handshake (code 1)', 'boom\nbadaboum');
const { message, detail } = describeStartFailure(err, 7317);
expect(message).toBe('Arboretum could not start its local server');
expect(detail).toContain('daemon exited before handshake (code 1)');
expect(detail).toContain('badaboum');
});
it('erreur quelconque (hors DaemonStartError) reste affichable', () => {
expect(describeStartFailure(new Error('ENOENT node'), 7317).detail).toContain('ENOENT node');
expect(describeStartFailure('cassé', 7317).detail).toContain('cassé');
});
it('sans queue de journal, aucun bloc « Server output » vide', () => {
const detail = describeStartFailure(new DaemonStartError('handshake', 'nope'), 7317).detail;
expect(detail).not.toContain('Server output');
});
});
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { mkdtempSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { installChanged, readInstallStamp } from '../src/main/upgrade-watch';
describe('upgrade-watch', () => {
it('lit une empreinte de fichier, et rien pour un chemin absent', () => {
const file = join(mkdtempSync(join(tmpdir(), 'arb-stamp-')), 'bin');
writeFileSync(file, 'v1', 'utf8');
const stamp = readInstallStamp(file);
expect(stamp?.size).toBe(2);
expect(readInstallStamp(join(file, 'nulle-part'))).toBeNull();
});
it('détecte le remplacement du binaire (mtime/taille)', () => {
const file = join(mkdtempSync(join(tmpdir(), 'arb-stamp-')), 'bin');
writeFileSync(file, 'v1', 'utf8');
const boot = readInstallStamp(file);
expect(installChanged(boot, readInstallStamp(file))).toBe(false);
writeFileSync(file, 'version deux', 'utf8');
utimesSync(file, new Date(), new Date(Date.now() + 5_000)); // dpkg pose un mtime plus récent
expect(installChanged(boot, readInstallStamp(file))).toBe(true);
});
it('une empreinte illisible ne conclut jamais à une mise à jour', () => {
const stamp = { ino: 1, mtimeMs: 2, size: 3 };
expect(installChanged(null, stamp)).toBe(false);
expect(installChanged(stamp, null)).toBe(false);
expect(installChanged(null, null)).toBe(false);
});
});