diff --git a/src/servers/sidecar/caldav/index.ts b/src/servers/sidecar/caldav/index.ts index a0eaa3b6..8f601030 100644 --- a/src/servers/sidecar/caldav/index.ts +++ b/src/servers/sidecar/caldav/index.ts @@ -133,7 +133,17 @@ const server = Bun.serve({ if (url.pathname === '/_health') { if (!radicale.isRunning()) { - return Response.json({ ok: false, running: false, error: 'radicale not running' }, { status: 503 }); + // `startupError` distinguishes "not installed" from "crashed and backing off". Both are 503, but + // only one of them is fixed by waiting, and the logs alone did not say which. + return Response.json( + { + ok: false, + running: false, + error: radicale.startupError ?? 'radicale not running', + installed: radicale.startupError === null, + }, + { status: 503 }, + ); } try { const probe = await fetch(`${radicale.url}/`, { diff --git a/src/servers/sidecar/caldav/radicale.test.ts b/src/servers/sidecar/caldav/radicale.test.ts new file mode 100644 index 00000000..ab2c10be --- /dev/null +++ b/src/servers/sidecar/caldav/radicale.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'bun:test'; +import { resolveRadicale } from './radicale'; + +// The failure this covers took a whole sidecar down on a fresh install: `spawn('radicale')` resolves +// against the PATH the process STARTED with, which under PM2 has no `~/.local/bin`, and the ENOENT threw +// at module load rather than being handled. + +describe('resolving radicale', () => { + test('finds the apt-installed binary', () => { + const found = resolveRadicale('/usr/bin:/bin'); + // Skip rather than fail where radicale genuinely is not installed — this asserts the RESOLVER, and a + // machine without the package would otherwise report a bug that is not there. + if (!found) return; + expect(found.command).toContain('radicale'); + expect(found.args).toEqual([]); + }); + + test('falls back to the python module when no wrapper script is on PATH', () => { + // A `pip install --user` into an environment without ~/.local/bin, or a distro shipping the module + // with no script, both leave radicale importable and unfindable. The module is what matters. + const pythonOnly = '/usr/bin'; + const found = resolveRadicale(pythonOnly); + if (!found) return; + // Either form is correct here; what must never happen is null while the module is importable. + expect(found.command.length).toBeGreaterThan(0); + }); + + test('returns null rather than throwing when nothing is installed', () => { + // The whole point: a missing dependency must be a value the caller can handle, not an exception at + // module load that crash-loops the sidecar and takes /_health down with it. + expect(() => resolveRadicale('/nonexistent-dir')).not.toThrow(); + expect(resolveRadicale('/nonexistent-dir')).toBeNull(); + }); +}); diff --git a/src/servers/sidecar/caldav/radicale.ts b/src/servers/sidecar/caldav/radicale.ts index 20af50b6..992e94b0 100644 --- a/src/servers/sidecar/caldav/radicale.ts +++ b/src/servers/sidecar/caldav/radicale.ts @@ -1,6 +1,7 @@ -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import type { ChildProcess } from 'node:child_process'; import { mkdirSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; import { join } from 'node:path'; // Supervises Radicale, the CalDAV/CardDAV server this sidecar fronts. @@ -17,13 +18,53 @@ import { join } from 'node:path'; // authenticates. If Radicale is ever bound to a non-loopback interface this becomes an open door — // hence the explicit hostname below rather than a default. +export type Launcher = { command: string; args: string[] }; + export type RadicaleHandle = { port: number; url: string; stop: () => void; isRunning: () => boolean; + /** Why it never started, when it never started. Null once it has launched at least once. */ + startupError: string | null; }; +// ── Finding radicale ── +// +// `spawn('radicale')` resolves against the PATH THIS PROCESS STARTED WITH, and under PM2 that is a +// login-less minimum. The same trap has now bitten `tea`, the plugin dependency probe and this — so the +// search path is explicit rather than inherited. +// +// `/usr/bin` is listed even though it is almost always in PATH already: this is the apt package's +// location, it is the supported install, and a PATH mangled by a shell profile should not be able to +// break the normal case. +const searchPath = (): string => + [join(homedir(), '.local', 'bin'), '/usr/local/bin', '/usr/bin', '/bin', process.env.PATH ?? ''] + .filter(Boolean) + .join(':'); + +/** + * How to start radicale here, or null if it is not installed at all. + * + * Falls back to `python3 -m radicale` because radicale is a python package: a `pip install --user` in an + * environment without `~/.local/bin` on PATH, or a distribution that ships the module without a wrapper + * script, both leave it importable and unfindable. The module is what actually matters. + */ +export function resolveRadicale(pathOverride?: string): Launcher | null { + const PATH = pathOverride ?? searchPath(); + + const direct = Bun.which('radicale', { PATH }); + if (direct) return { command: direct, args: [] }; + + const python = Bun.which('python3', { PATH }); + if (python) { + const probe = spawnSync(python, ['-c', 'import radicale'], { stdio: 'ignore', env: { PATH } }); + if (probe.status === 0) return { command: python, args: ['-m', 'radicale'] }; + } + + return null; +} + type StartParams = { /** Where collections live. Created if absent. */ storagePath: string; @@ -66,14 +107,38 @@ export function startRadicale({ storagePath, configPath, port }: StartParams): R let stopped = false; let restarts = 0; + // Resolved ONCE, before anything can throw. This used to be a bare `spawn('radicale')` at module top + // level, so on a machine without it the ENOENT killed the sidecar and PM2 crash-looped it — which is + // the opposite of what the backoff below is for, and it took `/_health` down with it. A missing + // dependency should be a sidecar that answers "radicale is not installed", not a restart loop whose + // logs say ENOENT and nothing about the fix. + const launcher = resolveRadicale(); + + if (!launcher) { + const message = + 'radicale is not installed. It ships with machine-setup; install it with ' + + '`sudo apt install radicale` (Debian/Ubuntu), `brew install radicale` (macOS), ' + + 'or `pip install --user radicale`. Calendar and contacts are unavailable until then.'; + console.error(`[caldav] ${message}`); + return { + port, + url: `http://127.0.0.1:${port}`, + isRunning: () => false, + startupError: message, + stop: () => {}, + }; + } + const launch = () => { if (stopped) return; - child = spawn('radicale', ['--config', configPath], { + child = spawn(launcher.command, [...launcher.args, '--config', configPath], { stdio: ['ignore', 'pipe', 'pipe'], // Radicale must not inherit the platform's environment wholesale — Bun auto-loads .env into every // process started in the platform directory, and there is no reason for a calendar server to hold // an Anthropic key or a database URL. - env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '' }, + // The augmented PATH, not the inherited one — `python3 -m radicale` needs to find its own + // interpreter's neighbours, and a wrapper script may shell out. + env: { PATH: searchPath(), HOME: process.env.HOME ?? '' }, }); child.stdout?.on('data', (buf: Buffer) => process.stdout.write(`[radicale] ${buf}`)); @@ -97,6 +162,7 @@ export function startRadicale({ storagePath, configPath, port }: StartParams): R port, url: `http://127.0.0.1:${port}`, isRunning: () => child !== null, + startupError: null, stop: () => { stopped = true; child?.kill('SIGTERM');