diff --git a/src/brainy.ts b/src/brainy.ts index 9ea8324a..2af17329 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -1262,6 +1262,15 @@ export class Brainy implements BrainyInterface { process.exit(0) } Brainy.beforeExitListener = async () => { + // Self-deregister FIRST: Node re-emits 'beforeExit' after every event- + // loop drain, and this flush schedules new async work — with the + // listener still attached, a script that never calls close() would spin + // flush → drain → flush forever and never exit. One flush, then the + // next drain finds no listener and the process exits. + if (Brainy.beforeExitListener) { + process.off('beforeExit', Brainy.beforeExitListener) + Brainy.beforeExitListener = undefined + } await flushOnShutdown() } process.on('SIGTERM', Brainy.sigtermListener) diff --git a/src/graph/graphAdjacencyIndex.ts b/src/graph/graphAdjacencyIndex.ts index c348fc3a..4dae3c43 100644 --- a/src/graph/graphAdjacencyIndex.ts +++ b/src/graph/graphAdjacencyIndex.ts @@ -878,6 +878,11 @@ export class GraphAdjacencyIndex implements GraphIndexProvider { this.flushTimer = setInterval(async () => { await this.flush() }, this.config.flushInterval) + // Background maintenance must never keep the host process alive — + // close()/flush() handle durability; the interval is best-effort. + if (typeof this.flushTimer.unref === 'function') { + this.flushTimer.unref() + } } /** diff --git a/src/graph/lsm/LSMTree.ts b/src/graph/lsm/LSMTree.ts index 960efe9e..9abfa353 100644 --- a/src/graph/lsm/LSMTree.ts +++ b/src/graph/lsm/LSMTree.ts @@ -517,6 +517,11 @@ export class LSMTree { } } }, this.config.compactionInterval) + // Background compaction must never keep the host process alive — + // close() compacts/flushes deterministically; this interval is best-effort. + if (this.compactionTimer && typeof this.compactionTimer.unref === 'function') { + this.compactionTimer.unref() + } } /** diff --git a/src/storage/adapters/baseStorageAdapter.ts b/src/storage/adapters/baseStorageAdapter.ts index 5d2067d0..a76d22df 100644 --- a/src/storage/adapters/baseStorageAdapter.ts +++ b/src/storage/adapters/baseStorageAdapter.ts @@ -431,6 +431,12 @@ export abstract class BaseStorageAdapter implements StorageAdapter { this.statisticsBatchUpdateTimerId = setTimeout(() => { this.flushStatistics() }, delayMs) + // Best-effort statistics flush — must not keep the process alive + // (close() flushes counts deterministically). + const statsTimer = this.statisticsBatchUpdateTimerId as unknown as { unref?: () => void } + if (statsTimer && typeof statsTimer.unref === 'function') { + statsTimer.unref() + } } /** diff --git a/src/utils/metadataWriteBuffer.ts b/src/utils/metadataWriteBuffer.ts index a0c0df50..4a2c7e2e 100644 --- a/src/utils/metadataWriteBuffer.ts +++ b/src/utils/metadataWriteBuffer.ts @@ -104,6 +104,11 @@ export class MetadataWriteBuffer { }) } }, this.flushIntervalMs) + // Best-effort background flush — must not keep the process alive + // (close() drains the buffer for durability). + if (this.flushTimer && typeof this.flushTimer.unref === 'function') { + this.flushTimer.unref() + } // Prevent timer from keeping the process alive if (this.flushTimer && typeof this.flushTimer === 'object' && 'unref' in this.flushTimer) { diff --git a/src/vfs/PathResolver.ts b/src/vfs/PathResolver.ts index 6babd922..e496c834 100644 --- a/src/vfs/PathResolver.ts +++ b/src/vfs/PathResolver.ts @@ -525,6 +525,10 @@ export class PathResolver { console.log(`[PathResolver] Cache stats: ${Math.round(hitRate * 100)}% hit rate, ${this.pathCache.size} entries, ${this.hotPaths.size} hot paths`) } }, 60000) // Every minute + // Cache maintenance must never keep the host process alive. + if (this.maintenanceTimer && typeof this.maintenanceTimer.unref === 'function') { + this.maintenanceTimer.unref() + } } /** diff --git a/src/vfs/VirtualFileSystem.ts b/src/vfs/VirtualFileSystem.ts index a6ba51a5..9dd5d39b 100644 --- a/src/vfs/VirtualFileSystem.ts +++ b/src/vfs/VirtualFileSystem.ts @@ -1546,6 +1546,10 @@ export class VirtualFileSystem implements IVirtualFileSystem { } } }, 60000) // Every minute + // Cache maintenance must never keep the host process alive. + if (this.backgroundTimer && typeof this.backgroundTimer.unref === 'function') { + this.backgroundTimer.unref() + } } private getDefaultConfig(): Required> & { rootEntityId?: string } { diff --git a/tests/unit/process-exit-sweep.test.ts b/tests/unit/process-exit-sweep.test.ts new file mode 100644 index 00000000..d0365d8c --- /dev/null +++ b/tests/unit/process-exit-sweep.test.ts @@ -0,0 +1,120 @@ +/** + * Process-exit sweep — no operation class may leave a ref'd timer behind. + * + * A consumer's clean-room verification of the close()-hang fix found the + * add-only path clean but `relate()` leaving ~5 uncleared Timeouts — the fix + * had covered a repro, not the bug class. This sweep turns the class off: + * for EVERY op family (add / relate / graph find / vfs), assert that after + * `close()` no NEW ref'd Timeout survives (unref'd timers are fine — they + * never keep the host process alive; that is the standard for every + * background-maintenance interval in brainy). + * + * Filesystem storage on purpose: it is the reported environment and the one + * that arms the most timers (LSM flush/compaction, write buffers, watchers). + */ +import { describe, it, expect, afterEach } from 'vitest' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { Brainy, NounType, VerbType } from '../../src/index.js' + +const tmpDirs: string[] = [] +function mkTmp(): string { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-exit-sweep-')) + tmpDirs.push(d) + return d +} +afterEach(() => { + for (const d of tmpDirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }) +}) + +const V = () => Array.from({ length: 384 }, () => Math.random()) + +/** Identity set of currently-ref'd Timeout handles (the only kind that can + * keep the event loop — and a bare consumer script — alive). */ +function refdTimeouts(): Set { + const handles: any[] = (process as any)._getActiveHandles?.() ?? [] + return new Set( + handles.filter( + (h) => + h?.constructor?.name === 'Timeout' && + (typeof h.hasRef !== 'function' || h.hasRef()) + ) + ) +} + +/** Describe a leaked timer well enough to find its source. */ +function describeTimer(h: any): string { + const fn = h?._onTimeout + return `Timeout(after=${h?._idleTimeout}ms, repeat=${!!h?._repeat}, fn=${ + fn?.name || 'anon' + }: ${String(fn).slice(0, 120).replace(/\s+/g, ' ')})` +} + +async function sweep(run: (brain: any) => Promise): Promise { + const before = refdTimeouts() + const brain: any = new Brainy({ + requireSubtype: false, + storage: { type: 'filesystem', path: mkTmp() }, + plugins: [], + silent: true + }) + await brain.init() + await run(brain) + await brain.close() + return [...refdTimeouts()].filter((h) => !before.has(h)).map(describeTimer) +} + +describe('Process-exit sweep — close() leaves no ref’d timers, per op class', () => { + it('add', async () => { + const leaked = await sweep(async (brain) => { + await brain.add({ data: 'solo', type: NounType.Concept, subtype: 's', vector: V() }) + }) + expect(leaked).toEqual([]) + }) + + it('add + relate (the reported hang shape)', async () => { + const leaked = await sweep(async (brain) => { + const a = await brain.add({ data: 'a', type: NounType.Concept, subtype: 's', vector: V() }) + const b = await brain.add({ data: 'b', type: NounType.Concept, subtype: 's', vector: V() }) + await brain.relate({ from: a, to: b, type: VerbType.RelatedTo, subtype: 's' }) + }) + expect(leaked).toEqual([]) + }) + + it('relate + graph find + related()', async () => { + const leaked = await sweep(async (brain) => { + const ids: string[] = [] + for (let i = 0; i < 6; i++) { + ids.push(await brain.add({ data: `n${i}`, type: NounType.Concept, subtype: 's', vector: V() })) + } + for (let i = 0; i + 1 < ids.length; i++) { + await brain.relate({ from: ids[i], to: ids[i + 1], type: VerbType.RelatedTo, subtype: 's' }) + } + await brain.find({ connected: { from: ids[0], depth: 2 } }) + await brain.related({ node: ids[1] }) + }) + expect(leaked).toEqual([]) + }) + + it('metadata find + update (write-buffer path)', async () => { + const leaked = await sweep(async (brain) => { + const id = await brain.add({ + data: 'meta', type: NounType.Concept, subtype: 's', + metadata: { wave: 1 }, vector: V() + }) + await brain.update({ id, metadata: { wave: 2 } }) + await brain.find({ where: { wave: { greaterThan: 1 } } }) + }) + expect(leaked).toEqual([]) + }) + + it('vfs write/read/tree (VFS + PathResolver timers)', async () => { + const leaked = await sweep(async (brain) => { + await brain.vfs.writeFile('/notes/a.md', 'alpha') + await brain.vfs.readFile('/notes/a.md') + await brain.vfs.getTreeStructure('/', { maxDepth: 2 }) + }) + expect(leaked).toEqual([]) + }) +})