From c7e8d7aac40357d402adca0b3987907a7f619547 Mon Sep 17 00:00:00 2001 From: Mark Bagley Date: Mon, 27 Jul 2026 23:56:55 -0700 Subject: [PATCH] Fix: Pulse crash-loops when any unwatchable node exists under LIFEOS/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `startWatchers()` in the wiki module watches `LIFEOS_DIR` recursively. A recursive watch walks every node beneath its root, and some cannot be opened — unix sockets, FIFOs, dead symlinks, unreadable directories. Those surface as an asynchronous 'error' event on the watcher. The surrounding `try/catch` only sees synchronous throws, so the event goes unhandled and terminates the process. Under a supervisor Pulse then crash-loops for as long as the node exists. Because the walk happens during startup, it presents as "Pulse won't start" rather than "one file is unwatchable", and the only clue is an ENXIO naming a file that has nothing to do with the wiki: error: ENXIO: no such device or address, open '.../MEMORY//.sock' at emitError (node:events:51:13) at #onEvent (node:fs:47:16) It is easy to miss because it is latent: a long-running Pulse is unaffected when the socket appears, since the walk already happened. It only bites on the next restart, which can be weeks later and looks unrelated to whatever created the file. Any tool that puts a socket under LIFEOS/MEMORY/ — a bot, an IPC helper, an editor server — is enough to trigger it. Two changes: 1. Narrow the watch roots to what the reindexer actually consumes (DOCUMENTATION, KNOWLEDGE, ALGORITHM). Watching all of LIFEOS_DIR was both too broad and too narrow — too broad because MEMORY/ is the busiest subtree in an install and the place where non-regular files appear, too narrow because skills/ and hooks/ live under ~/.claude rather than LIFEOS/, so changes there were never watched anyway. This removes the cause and cuts a large amount of pointless wakeup churn from .jsonl appends. 2. Attach an 'error' handler to the watcher so any future unopenable node degrades to "no live reindex" instead of taking Pulse down. The comment in the existing catch already says watching is best-effort; this makes that true. The same async-error gap exists in `user-index.ts`, which watches USER_DIR with an identical `try/catch`, so it gets the same handler. Its initial scan has already built the index, so losing live updates is a soft degradation. Verified on Linux (bun 1.3.13): before, Pulse crash-looped with a socket present and `/healthz` never came up; after, it starts clean, logs the watcher warning once when applicable, and serves normally. No new typecheck errors in either file. --- .../LIFEOS/PULSE/modules/user-index.ts | 7 +++++++ LifeOS/install/LIFEOS/PULSE/modules/wiki.ts | 20 ++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/LifeOS/install/LIFEOS/PULSE/modules/user-index.ts b/LifeOS/install/LIFEOS/PULSE/modules/user-index.ts index b1430ad610..7e482f5236 100644 --- a/LifeOS/install/LIFEOS/PULSE/modules/user-index.ts +++ b/LifeOS/install/LIFEOS/PULSE/modules/user-index.ts @@ -570,6 +570,13 @@ export async function start(): Promise { if (!filename || !filename.endsWith(".md")) return reindexDebounced(`${event}:${filename}`) }) + // Same async-error gap as the wiki watcher: an unopenable node under USER_DIR + // (socket, FIFO, dead symlink) emits 'error' asynchronously, which the catch + // below cannot see, and unhandled it takes the process down. The initial scan + // has already built the index, so log and carry on without live updates. + state.watcher.on("error", (err: unknown) => { + console.warn(`[${MODULE_NAME}] Watcher error (continuing without it):`, err) + }) console.log(`[${MODULE_NAME}] Watching ${USER_DIR}`) } catch (err) { console.warn(`[${MODULE_NAME}] Watch failed, polling disabled:`, err) diff --git a/LifeOS/install/LIFEOS/PULSE/modules/wiki.ts b/LifeOS/install/LIFEOS/PULSE/modules/wiki.ts index fbf3ec2bd6..3662f7259a 100644 --- a/LifeOS/install/LIFEOS/PULSE/modules/wiki.ts +++ b/LifeOS/install/LIFEOS/PULSE/modules/wiki.ts @@ -485,7 +485,13 @@ const pendingPaths: Set = new Set() function startWatchers(): void { stopWatchers() - const watchPaths = [LIFEOS_DIR] + // Watch only the roots the reindexer consumes. Watching all of LIFEOS_DIR is + // both too broad and too narrow: too broad because it pulls in MEMORY/, the + // busiest subtree in an install (constant .jsonl appends) and the place where + // non-regular files appear — a unix socket there kills a recursive watcher + // outright; too narrow because skills/ and hooks/ live under ~/.claude, not + // under LIFEOS/, so changes there were never actually watched. + const watchPaths = [DOCUMENTATION_DIR, KNOWLEDGE_DIR, ALGORITHM_DIR] for (const watchPath of watchPaths) { if (!existsSync(watchPath)) continue @@ -499,6 +505,18 @@ function startWatchers(): void { } }) + // A recursive watch walks every node beneath its root, including ones that + // cannot be opened: unix sockets, FIFOs, dead symlinks, unreadable dirs. + // Those surface as an ASYNC 'error' event on the watcher, which the catch + // below cannot see — unhandled, it terminates the process. Under a + // supervisor Pulse then crash-loops for as long as the node exists, and + // because the walk happens during startup it presents as "Pulse won't + // start" rather than "one file is unwatchable". Watching is best-effort by + // design, so degrade to that instead of dying. + watcher.on("error", (err: unknown) => { + console.warn(`[wiki] watcher error on ${watchPath} (continuing without it): ${String(err)}`) + }) + watchers.push(watcher) } catch { // File watching is best-effort; manual refresh still rebuilds on restart.