/** * 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([]) }) })