fix(hooks): harden memory/hook helpers (timeout, signals, truncation, cross-platform slug)

FIX 1 — runWithTimeout was inert: it called fn() then clearTimeout()
immediately, so an async callee's hang was never caught (the timer was
cleared before the pending promise settled). Reimplement as a real
Promise.race between the work and the timeout. Document that a synchronous
blocking callee cannot be preempted in-process (the real guard is the
file-size cap in intelligence.cjs). Applied to both hook-handler.cjs copies.

FIX 2 — auto-memory-hook.mjs swallowed ALL unhandled rejections process-wide
via `() => {}`. Keep hooks exit-0 but log the reason under RUFLO_DEBUG/DEBUG
so genuine async bugs are visible.

FIX 3 — no SIGTERM/SIGINT cleanup in the db-touching helpers. Track the active
backend and flush it (JSON persist / SQLite close + WAL flush) on signal,
avoiding half-written stores and stale agentdb.rvf.lock.

FIX 4 — silent value truncation (intelligence.cjs 500/100 chars). Add clip():
appends an ellipsis and warns under debug when it actually cuts.

FIX 5 — projectSlug only handled POSIX '/', so on Windows it never matched
Claude Code's real ~/.claude/projects/<slug> dir and memory bootstrap silently
found nothing. Slugify every non-alphanumeric to '-' to match Claude's
convention (verified: G:\My Drive\...\ruflo-fix -> G--My-Drive-...-ruflo-fix).

Also: export runWithTimeout behind a require.main guard so it is unit-testable,
and add tests/hook-handler-runwithtimeout.test.cjs (node:test, 5 cases incl.
the decisive slow-async timeout case).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
tjaiyen
2026-06-16 08:40:24 -07:00
co-authored by Claude Fable 5
parent ca0a6fa5cb
commit cb1e93e8db
6 changed files with 198 additions and 43 deletions
+34 -5
View File
@@ -31,6 +31,27 @@ const log = (msg) => console.log(`${CYAN}[AutoMemory] ${msg}${RESET}`);
const success = (msg) => console.log(`${GREEN}[AutoMemory] ✓ ${msg}${RESET}`);
const dim = (msg) => console.log(` ${DIM}${msg}${RESET}`);
const DEBUG = !!(process.env.RUFLO_DEBUG || process.env.DEBUG);
// ── Graceful shutdown (FIX 3) ───────────────────────────────────────────────
// Track the backend currently in use so a SIGTERM/SIGINT mid-run can still
// flush it (the JSON backend persists, a SQLite-backed one closes/flushes WAL)
// instead of leaving a half-written store or a stale lock behind.
let activeBackend = null;
let shuttingDown = false;
function trackBackend(b) { activeBackend = b; return b; }
async function gracefulExit(signal) {
if (shuttingDown) return;
shuttingDown = true;
if (DEBUG) process.stderr.write(`[AutoMemory] received ${signal}, flushing backend before exit\n`);
try {
if (activeBackend && typeof activeBackend.shutdown === 'function') await activeBackend.shutdown();
} catch { /* best effort — never block exit on cleanup */ }
process.exit(0);
}
process.on('SIGTERM', () => { gracefulExit('SIGTERM'); });
process.on('SIGINT', () => { gracefulExit('SIGINT'); });
// Ensure data dir
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
@@ -219,7 +240,7 @@ async function doImport() {
}
const config = readConfig();
const backend = new JsonFileBackend(STORE_PATH);
const backend = trackBackend(new JsonFileBackend(STORE_PATH));
await backend.initialize();
const bridgeConfig = {
@@ -302,7 +323,7 @@ async function doSync() {
}
const config = readConfig();
const backend = new JsonFileBackend(STORE_PATH);
const backend = trackBackend(new JsonFileBackend(STORE_PATH));
await backend.initialize();
const entryCount = await backend.count();
@@ -542,9 +563,17 @@ async function doImportAll() {
const command = process.argv[2] || 'status';
// Suppress unhandled rejection warnings from dynamic import() failures
// which can cause non-zero exit codes even when caught
process.on('unhandledRejection', () => {});
// Dynamic import() failures can surface as unhandled rejections on a later
// microtask even when the awaiting call site already caught them, which would
// otherwise force a non-zero exit. Swallow to keep hooks exit-0, but surface the
// reason under RUFLO_DEBUG/DEBUG so genuine async bugs aren't silently hidden
// (FIX 2 — the previous `() => {}` discarded every rejection process-wide).
process.on('unhandledRejection', (reason) => {
if (DEBUG) {
const detail = reason && reason.message ? reason.message : String(reason);
process.stderr.write(`[AutoMemory] unhandledRejection (suppressed): ${detail}\n`);
}
});
try {
switch (command) {
+26 -4
View File
@@ -59,6 +59,28 @@ const AUTOPILOT_STATE_PATH = join(DATA_DIR, 'autopilot-state.json');
// Approximate tokens per character (Claude averages ~3.5 chars per token)
const CHARS_PER_TOKEN = 3.5;
const DEBUG = !!(process.env.RUFLO_DEBUG || process.env.DEBUG);
// ── Graceful shutdown (FIX 3) ───────────────────────────────────────────────
// The active backend is created mid-handler and closed at the end. SQLite holds
// a native handle and a WAL; if a SIGTERM/SIGINT arrives between creation and
// `backend.shutdown()`, that close is skipped — risking an unflushed WAL or a
// stale lock file. Track the active backend and flush it on signal before exit.
let activeBackend = null;
let shuttingDown = false;
function trackBackend(b) { activeBackend = b; return b; }
async function gracefulExit(signal) {
if (shuttingDown) return;
shuttingDown = true;
if (DEBUG) process.stderr.write(`[ContextPersistence] received ${signal}, flushing backend before exit\n`);
try {
if (activeBackend && typeof activeBackend.shutdown === 'function') await activeBackend.shutdown();
} catch { /* best effort — never block exit on cleanup */ }
process.exit(0);
}
process.on('SIGTERM', () => { gracefulExit('SIGTERM'); });
process.on('SIGINT', () => { gracefulExit('SIGINT'); });
// Ensure data dir
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
@@ -714,7 +736,7 @@ async function resolveBackend() {
try {
const backend = new SQLiteBackend(ARCHIVE_DB_PATH);
await backend.initialize();
return { backend, type: 'sqlite' };
return { backend: trackBackend(backend), type: 'sqlite' };
} catch { /* fall through */ }
// Tier 2: RuVector PostgreSQL (TB-scale, vector search, GNN)
@@ -723,7 +745,7 @@ async function resolveBackend() {
if (rvConfig) {
const backend = new RuVectorBackend(rvConfig);
await backend.initialize();
return { backend, type: 'ruvector' };
return { backend: trackBackend(backend), type: 'ruvector' };
}
} catch { /* fall through */ }
@@ -739,14 +761,14 @@ async function resolveBackend() {
if (memPkg?.AgentDBBackend) {
const backend = new memPkg.AgentDBBackend();
await backend.initialize();
return { backend, type: 'agentdb' };
return { backend: trackBackend(backend), type: 'agentdb' };
}
} catch { /* fall through */ }
// Tier 4: JSON file (always works)
const backend = new JsonFileBackend(ARCHIVE_JSON_PATH);
await backend.initialize();
return { backend, type: 'json' };
return { backend: trackBackend(backend), type: 'json' };
}
// ============================================================================
+38 -16
View File
@@ -37,20 +37,35 @@ const intelligence = safeRequire(path.join(helpersDir, 'intelligence.cjs'));
// ── Intelligence timeout protection (fixes #1530, #1531) ───────────────────
var INTELLIGENCE_TIMEOUT_MS = 3000;
// Race the (possibly-async) work against a real timeout.
//
// The previous implementation called `fn()` and then `clearTimeout(timer)`
// immediately — but if `fn()` returns a *pending* promise (async work), the
// timer was cancelled before the work settled and the promise resolved with the
// still-pending promise, so the timeout protected nothing. This version only
// settles when the work resolves OR the timeout fires, whichever comes first,
// and clears the timer afterwards.
//
// LIMITATION (be honest): a *synchronous* CPU-bound `fn` (e.g. the current
// intelligence.init(), which does blocking fs reads) cannot be interrupted by
// any in-process timer — the event loop is blocked, so the timeout callback
// can't run until `fn` already returned. The real guard for that case lives in
// intelligence.cjs (MAX_DATA_FILE_SIZE / MAX_GRAPH_NODES caps). This util only
// bounds work that yields to the event loop (async I/O, dynamic import, etc.).
function runWithTimeout(fn, label) {
return new Promise(function(resolve) {
var timer = setTimeout(function() {
var timer;
var timeout = new Promise(function(resolve) {
timer = setTimeout(function() {
process.stderr.write("[WARN] " + label + " timed out after " + INTELLIGENCE_TIMEOUT_MS + "ms, skipping\n");
resolve(null);
}, INTELLIGENCE_TIMEOUT_MS);
try {
var result = fn();
clearTimeout(timer);
resolve(result);
} catch (e) {
clearTimeout(timer);
resolve(null);
}
});
// Promise.resolve().then(fn) turns a synchronous throw into a rejection so it
// is handled here rather than escaping the Promise constructor.
var work = Promise.resolve().then(fn).catch(function() { return null; });
return Promise.race([work, timeout]).then(function(result) {
clearTimeout(timer);
return result;
});
}
@@ -261,9 +276,16 @@ if (command && handlers[command]) {
}
}
main().catch(function(e) {
console.log('[WARN] Hook handler error: ' + e.message);
}).finally(function() {
// Ensure clean exit for Claude Code hooks
process.exit(0);
});
// Only dispatch hooks when run directly (node hook-handler.cjs ...). When
// require()'d by a test, just expose the internals so they can be unit-tested
// without triggering stdin reads / process.exit.
if (require.main === module) {
main().catch(function(e) {
console.log('[WARN] Hook handler error: ' + e.message);
}).finally(function() {
// Ensure clean exit for Claude Code hooks
process.exit(0);
});
}
module.exports = { runWithTimeout: runWithTimeout, INTELLIGENCE_TIMEOUT_MS: INTELLIGENCE_TIMEOUT_MS };
+27 -5
View File
@@ -56,6 +56,21 @@ function tokenize(text) {
return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter(function(w) { return w.length > 2; });
}
// ── Truncation transparency (FIX 4) ─────────────────────────────────────────
// Silently severing stored values means later reasoning can be built on
// incomplete text. Warn (debug-gated) and mark the cut point with an ellipsis
// so it is visible that the value was clipped.
var DEBUG = !!(process.env.RUFLO_DEBUG || process.env.DEBUG);
function clip(text, max, label) {
text = text == null ? "" : String(text);
if (text.length <= max) return text;
if (DEBUG) {
process.stderr.write("[INTELLIGENCE] WARN: truncated " + (label || "value") +
" from " + text.length + " to " + max + " chars\n");
}
return text.slice(0, max - 1) + "…";
}
// ── Deduplication helper (fixes #1518) ──────────────────────────────────────
function deduplicateById(entries) {
if (!entries || !Array.isArray(entries)) return entries;
@@ -73,8 +88,14 @@ function deduplicateById(entries) {
function bootstrapFromMemoryFiles() {
var entries = [];
// Scope to current project only (not all 51+ project dirs)
var projectSlug = process.cwd().replace(/^\//, '').replace(/\//g, '-');
// Scope to current project only (not all 51+ project dirs).
// Match Claude Code's project-dir slug convention: every non-alphanumeric char
// becomes '-' (e.g. "G:\\My Drive\\TJ_Vault" -> "G--My-Drive-TJ-Vault"). The old
// version only stripped a leading '/' and replaced '/', so on Windows the slug
// kept ':' and '\\' and never matched the real ~/.claude/projects/<slug> dir —
// memory bootstrap then silently found nothing (FIX 5). Runs are NOT collapsed:
// Claude emits "G--My" because ':' and '\\' each map to their own '-'.
var projectSlug = process.cwd().replace(/[^a-zA-Z0-9]/g, '-');
var candidates = [
path.join(os.homedir(), ".claude", "projects", projectSlug, "memory"),
path.join(process.cwd(), ".claude-flow", "memory"),
@@ -101,14 +122,15 @@ function bootstrapFromMemoryFiles() {
for (var s = 0; s < sections.length; s++) {
var lines = sections[s].split("\n");
var title = lines[0] ? lines[0].trim() : "section-" + s;
var clippedContent = clip(sections[s], 500, "memory content");
entries.push({
id: "mem-" + entries.length,
content: sections[s].substring(0, 500),
summary: title.substring(0, 100),
content: clippedContent,
summary: clip(title, 100, "memory summary"),
category: "memory",
confidence: 0.5,
sourceFile: files[k],
words: tokenize(sections[s].substring(0, 500)),
words: tokenize(clippedContent),
});
}
} catch (e) { /* skip */ }
@@ -0,0 +1,56 @@
'use strict';
/**
* Unit tests for hook-handler.cjs runWithTimeout (FIX 1).
*
* The previous implementation called fn() and clearTimeout(timer) immediately,
* so an async fn returned a *pending* promise that resolved through the race —
* the timeout never fired. The "times out a slow async fn" case below fails
* against the old code (it would return 'late') and passes against the fix.
*
* Uses node:test (built-in) so it runs without installing dependencies.
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const { runWithTimeout, INTELLIGENCE_TIMEOUT_MS } = require(
path.join(__dirname, '..', '.claude', 'helpers', 'hook-handler.cjs')
);
test('returns the value for a fast async fn', async () => {
const r = await runWithTimeout(() => Promise.resolve(42), 'fast-async');
assert.equal(r, 42);
});
test('returns the value for a fast sync fn', async () => {
const r = await runWithTimeout(() => 7, 'fast-sync');
assert.equal(r, 7);
});
test('resolves null (never rejects) when fn throws synchronously', async () => {
const r = await runWithTimeout(() => { throw new Error('boom'); }, 'sync-throw');
assert.equal(r, null);
});
test('resolves null (never rejects) when an async fn rejects', async () => {
const r = await runWithTimeout(() => Promise.reject(new Error('boom')), 'async-reject');
assert.equal(r, null);
});
test('times out a slow async fn and resolves null near the timeout', { timeout: 8000 }, async () => {
const start = Date.now();
const r = await runWithTimeout(
() => new Promise((res) => {
// .unref() so the dangling timer never keeps the test process alive
const t = setTimeout(() => res('late'), INTELLIGENCE_TIMEOUT_MS + 2000);
if (t.unref) t.unref();
}),
'slow-async'
);
const elapsed = Date.now() - start;
assert.equal(r, null, "should time out to null, not return the late value");
assert.ok(
elapsed >= INTELLIGENCE_TIMEOUT_MS - 200 && elapsed < INTELLIGENCE_TIMEOUT_MS + 1500,
`should resolve near the ${INTELLIGENCE_TIMEOUT_MS}ms timeout, took ${elapsed}ms`
);
});
@@ -49,23 +49,27 @@ const intelligence = safeRequire(path.join(helpersDir, 'intelligence.cjs'));
// ── Intelligence timeout protection (fixes #1530, #1531) ───────────────────
const INTELLIGENCE_TIMEOUT_MS = 3000;
// Race the (possibly-async) work against a real timeout. The previous version
// called fn() and clearTimeout(timer) immediately, so an async fn returned a
// pending promise that resolved THROUGH the race — the timeout protected
// nothing. This settles on whichever finishes first, then clears the timer.
//
// LIMITATION: a synchronous blocking fn (the current intelligence.init() does
// blocking fs reads) cannot be interrupted by any in-process timer — the event
// loop is blocked. The real guard for that case is the readJSON file-size
// limit in intelligence.cjs. This util only bounds work that yields (async I/O).
function runWithTimeout(fn, label) {
// For synchronous blocking calls, we use a global safety timer.
// The readJSON file-size guard prevents loading huge files, but this
// is an additional safety net.
return new Promise((resolve) => {
const timer = setTimeout(() => {
let timer;
const timeout = new Promise((resolve) => {
timer = setTimeout(() => {
process.stderr.write("[WARN] " + label + " timed out after " + INTELLIGENCE_TIMEOUT_MS + "ms, skipping\n");
resolve(null);
}, INTELLIGENCE_TIMEOUT_MS);
try {
const result = fn();
clearTimeout(timer);
resolve(result);
} catch (e) {
clearTimeout(timer);
resolve(null);
}
});
const work = Promise.resolve().then(fn).catch(() => null);
return Promise.race([work, timeout]).then((result) => {
clearTimeout(timer);
return result;
});
}