diff --git a/README.fr.md b/README.fr.md index fb9401e..9b00907 100644 --- a/README.fr.md +++ b/README.fr.md @@ -333,6 +333,7 @@ Variables d'environnement : | `ARBORETUM_EMIT_TOKEN_FD` | daemon | Écrit le jeton d'accès sur ce descripteur de fichier au démarrage. Utilisé par l'app de bureau pour s'auto-connecter ; pas destiné à un usage manuel. | | `XDG_DATA_HOME` | daemon | Racine du répertoire de données (voir ci-dessus). | | `ARBORETUM_SHELL` | daemon (Windows) | Shell utilisé pour les commandes de projet. Défaut `powershell.exe`. | +| `ARBORETUM_DESKTOP_PORT` | app de bureau | Port sur lequel l'app de bureau lance son propre daemon. Défaut `7317`. À définir quand un autre Arboretum occupe ce port en permanence (service installé par `arboretum install`, ou daemon lancé en terminal). | Les réglages au-delà des flags CLI (les répertoires qu'Arboretum scanne pour trouver des repos et à quelle fréquence, le chemin et le home du binaire `claude`, et les fenêtres de rétention / purge des sessions) vivent dans les **Réglages** de l'UI. Ils sont diffusés via le WebSocket, donc chaque navigateur connecté reflète un changement en temps réel, sans rechargement. diff --git a/README.md b/README.md index e834edb..1967fd1 100644 --- a/README.md +++ b/README.md @@ -333,6 +333,7 @@ Environment variables: | `ARBORETUM_EMIT_TOKEN_FD` | daemon | Write the access token to this file descriptor at start-up. Used by the desktop app to sign itself in; not meant for manual use. | | `XDG_DATA_HOME` | daemon | Root of the data directory (see above). | | `ARBORETUM_SHELL` | daemon (Windows) | Shell used to run project commands. Default `powershell.exe`. | +| `ARBORETUM_DESKTOP_PORT` | desktop app | Port the desktop app starts its own daemon on. Default `7317`. Set it when another Arboretum permanently holds that port (a service installed with `arboretum install`, or one you run in a terminal). | Settings beyond CLI flags (the directories Arboretum scans for repos and how often, the `claude` binary path and home, and the session retention / purge windows) live in **Settings** in the UI. They are broadcast over the WebSocket, so every connected browser reflects a change in real time, no reload needed. diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 93d9445..698cc71 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -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 `/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 `/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 diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 5e17f5c..cb01fc3 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -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 `/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 +`/logs/daemon.log`. If the daemon dies *after* startup, the app offers to restart it rather +than leaving a dead window on screen. + +`` 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 diff --git a/packages/desktop/src/main/daemon.ts b/packages/desktop/src/main/daemon.ts index 7c81353..81880ab 100644 --- a/packages/desktop/src/main/daemon.ts +++ b/packages/desktop/src/main/daemon.ts @@ -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; } @@ -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 { +export async function startDaemon(opts: StartDaemonOptions): Promise { 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 => 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 { + 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 + } +} diff --git a/packages/desktop/src/main/main.ts b/packages/desktop/src/main/main.ts index 32208f3..3da64d1 100644 --- a/packages/desktop/src/main/main.ts +++ b/packages/desktop/src/main/main.ts @@ -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 { + 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 { - 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 { + 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 { + 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 { + 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 { + 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 { 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(); } } diff --git a/packages/desktop/src/main/port-guard.ts b/packages/desktop/src/main/port-guard.ts new file mode 100644 index 0000000..6fc6051 --- /dev/null +++ b/packages/desktop/src/main/port-guard.ts @@ -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 { + 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)); +} diff --git a/packages/desktop/src/main/start-failure.ts b/packages/desktop/src/main/start-failure.ts new file mode 100644 index 0000000..87a3bf9 --- /dev/null +++ b/packages/desktop/src/main/start-failure.ts @@ -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'); +} diff --git a/packages/desktop/src/main/upgrade-watch.ts b/packages/desktop/src/main/upgrade-watch.ts new file mode 100644 index 0000000..70fdcdc --- /dev/null +++ b/packages/desktop/src/main/upgrade-watch.ts @@ -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; +} diff --git a/packages/desktop/test/port-guard.test.ts b/packages/desktop/test/port-guard.test.ts new file mode 100644 index 0000000..9b41a51 --- /dev/null +++ b/packages/desktop/test/port-guard.test.ts @@ -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 => ({ 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); +}); diff --git a/packages/desktop/test/start-failure.test.ts b/packages/desktop/test/start-failure.test.ts new file mode 100644 index 0000000..488daf2 --- /dev/null +++ b/packages/desktop/test/start-failure.test.ts @@ -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'); + }); +}); diff --git a/packages/desktop/test/upgrade-watch.test.ts b/packages/desktop/test/upgrade-watch.test.ts new file mode 100644 index 0000000..f86789f --- /dev/null +++ b/packages/desktop/test/upgrade-watch.test.ts @@ -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); + }); +});