mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-09-28 14:32:58 +08:00
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:
co-authored by
Claude Fable 5
parent
ca0a6fa5cb
commit
cb1e93e8db
@@ -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) {
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user