Summary
LIFEOS/PULSE/modules/wiki.ts → startWatchers() (~L494) registers a recursive fs.watch over LIFEOS_DIR, guarded only by a synchronous try/catch (L493–L503):
try {
const watcher = watch(watchPath, { recursive: true }, (_event, filename) => {
if (!filename) return
const filenameText = String(filename)
if (filenameText.endsWith(".md")) {
scheduleReindex(join(watchPath, filenameText))
}
})
watchers.push(watcher)
} catch {
// File watching is best-effort; manual refresh still rebuilds on restart.
}
That catch runs at registration time and cannot catch the watcher's asynchronous 'error' event. On Linux, a recursive watch that meets a broken symlink (or an ELOOP) anywhere under the watched tree emits an 'error' event; with no handler attached, the runtime rethrows it and the whole Pulse process exits at boot, so :31337 never binds.
This is the same failure class as #1186 (closed — "Pulse crashes on Linux: a self-pointing symlink (ELOOP)"). #1186 was resolved by removing that one offending symlink, but the underlying watcher still has no error handler at HEAD (v7.1.1), so any other broken symlink under LIFEOS_DIR reproduces the crash. The instance was fixed; the class is still open.
Environment
Ubuntu, LifeOS v7.1.1, Pulse launched via bun run pulse.ts.
Steps to reproduce
- Create a broken symlink anywhere under
~/.claude/LIFEOS — e.g. one whose target was moved or never existed on this host (common after a data move or migration):
ln -s /path/that/does/not/exist \
~/.claude/LIFEOS/MEMORY/WORK/example/inbox/thing.pdf
- Start Pulse.
Observed
All modules load, then Pulse exits before binding :31337:
error: ENOENT: no such file or directory, open '<LIFEOS_DIR>/MEMORY/WORK/<...>/inbox/<file>'
at emitError (node:events:...)
at #onEvent (node:fs:...)
GET :31337/ → connection refused.
Compounding factor (diagnosability): start-pulse.sh:6 launches with bun run pulse.ts 2>/dev/null, which discards stderr — so the crash-loop is invisible to the operator, and the ENOENT above only appears if you run pulse.ts directly. Might be worth not discarding stderr there (or teeing it to a log) so boot crashes surface.
Fix suggestion
Attach an 'error' handler so an async fs error is logged and skipped rather than crashing the dashboard — consistent with the existing "File watching is best-effort" comment at L504:
const watcher = watch(watchPath, { recursive: true }, (_event, filename) => {
if (!filename) return
const filenameText = String(filename)
if (filenameText.endsWith(".md")) {
scheduleReindex(join(watchPath, filenameText))
}
})
// Recursive watch over LIFEOS_DIR will meet broken symlinks / ELOOP under the
// tree; those surface as ASYNC 'error' events the surrounding try/catch cannot
// catch, and an unhandled watcher error crashes all of Pulse at boot (cf. #1186).
watcher.on("error", (err) => {
console.warn(`[wiki] watch error on ${watchPath} (continuing, best-effort): ${String(err)}`)
})
watchers.push(watcher)
Verified locally: with the handler attached, the same broken symlink now logs a single [wiki] watch error … ENOENT … line and Pulse boots and binds :31337 normally.
Thanks as always — appreciate how quickly these get triaged given the pace the project moves at.
Summary
LIFEOS/PULSE/modules/wiki.ts→startWatchers()(~L494) registers a recursivefs.watchoverLIFEOS_DIR, guarded only by a synchronoustry/catch(L493–L503):That
catchruns at registration time and cannot catch the watcher's asynchronous'error'event. On Linux, a recursive watch that meets a broken symlink (or an ELOOP) anywhere under the watched tree emits an'error'event; with no handler attached, the runtime rethrows it and the whole Pulse process exits at boot, so:31337never binds.This is the same failure class as #1186 (closed — "Pulse crashes on Linux: a self-pointing symlink (ELOOP)"). #1186 was resolved by removing that one offending symlink, but the underlying watcher still has no error handler at HEAD (v7.1.1), so any other broken symlink under
LIFEOS_DIRreproduces the crash. The instance was fixed; the class is still open.Environment
Ubuntu, LifeOS v7.1.1, Pulse launched via
bun run pulse.ts.Steps to reproduce
~/.claude/LIFEOS— e.g. one whose target was moved or never existed on this host (common after a data move or migration):ln -s /path/that/does/not/exist \ ~/.claude/LIFEOS/MEMORY/WORK/example/inbox/thing.pdfObserved
All modules load, then Pulse exits before binding
:31337:GET :31337/→ connection refused.Compounding factor (diagnosability):
start-pulse.sh:6launches withbun run pulse.ts 2>/dev/null, which discards stderr — so the crash-loop is invisible to the operator, and the ENOENT above only appears if you runpulse.tsdirectly. Might be worth not discarding stderr there (or teeing it to a log) so boot crashes surface.Fix suggestion
Attach an
'error'handler so an async fs error is logged and skipped rather than crashing the dashboard — consistent with the existing "File watching is best-effort" comment at L504:Verified locally: with the handler attached, the same broken symlink now logs a single
[wiki] watch error … ENOENT …line and Pulse boots and binds:31337normally.Thanks as always — appreciate how quickly these get triaged given the pace the project moves at.