caldav: a missing radicale should not crash-loop the sidecar

reported from a fresh install: ENOENT spawning radicale, over and over.

two bugs, and the second is the worse one.

`spawn('radicale')` resolves against the PATH THIS PROCESS STARTED WITH, and
under pm2 that is a login-less minimum with no ~/.local/bin. the same trap has
now bitten `tea`, the plugin dependency probe and this, so the search path is
explicit here rather than inherited: ~/.local/bin, /usr/local/bin, /usr/bin,
/bin, then whatever pm2 supplied. /usr/bin is listed even though it is
normally present — it is where the apt package lands, and a PATH mangled by a
shell profile should not break the supported install.

falls back to `python3 -m radicale`, because radicale is a python package: a
pip install --user without ~/.local/bin on PATH, or a distro shipping the
module with no wrapper script, both leave it importable and unfindable. the
module is the thing that matters.

the second bug: startRadicale runs at module top level, so the ENOENT threw
and killed the sidecar, and pm2 restarted it forever. that is the opposite of
what the backoff inside it is for, and it took /_health down with it — so the
one endpoint that could have said what was wrong was the one thing not
answering. a missing dependency is now a value the caller handles: the sidecar
stays up, /_health returns 503 with the install command, and `installed:
false` distinguishes "never installed" from "crashed and backing off". both
are 503 but only one is fixed by waiting.

resolveRadicale takes an optional PATH so the null branch is tested rather
than reasoned about. verified the happy path live: radicale launched, sidecar
registered, zero restarts.
This commit is contained in:
2026-08-19 21:03:14 +00:00
parent 5a26cdbbcb
commit c6ef0b34db
3 changed files with 114 additions and 4 deletions
+11 -1
View File
@@ -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}/`, {
@@ -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();
});
});
+69 -3
View File
@@ -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');