Skip to content

v7.1.1: PULSE/modules/wiki.ts recursive watcher has no 'error' handler — a broken symlink under LIFEOS_DIR crashes Pulse at boot (#1186 root cause still open) #1568

Description

@christauff

Summary

LIFEOS/PULSE/modules/wiki.tsstartWatchers() (~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

  1. 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
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions