brainy/tests/unit/process-exit-sweep.test.ts

121 lines
4.4 KiB
TypeScript
Raw Normal View History

fix: no script shape can hang on brainy's internals — unref every maintenance timer + one-shot beforeExit A consumer's clean-room verification of the 8.0.10 fix found relate() still hanging their scripts. Root-causing the CLASS instead of the repro found two mechanisms: 1. The 'beforeExit' auto-flush hook looped forever on any script that never reaches close(): Node re-emits beforeExit after every event-loop drain, and the async flush schedules new work — flush, drain, flush, forever. The listener now self-deregisters BEFORE its one flush, so the next drain exits. (Empirically: process.on('beforeExit', async () => await anything) alone never exits — this was the deepest root of the whole hang class.) 2. Every background-maintenance interval is now unref'd at creation — graph auto-flush, LSM compaction, metadata write-buffer flush, VFS cache maintenance, PathResolver cache maintenance, statistics debounce (the writer-lock heartbeat, flush watcher, and cache monitors already were). Durability is owned by close() and the beforeExit flush, both deterministic; a best-effort interval must never keep the host process alive. Proof: the reported shape (add x2 + relate, filesystem) exits cleanly BOTH with close() (~0.5 s) and with NO teardown at all — and in the no-teardown case the beforeExit flush still lands the data (verified by reopen: both nouns + the edge present). New per-op-class sweep test asserts no ref'd timer survives close() for add / relate / graph find / metadata update / vfs — turning this bug class off permanently instead of per-repro.
2026-07-02 17:26:22 -07:00
/**
* 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<unknown> {
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<void>): Promise<string[]> {
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 refd 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([])
})
})