brainy/tests/unit/shutdown-hooks-lifecycle.test.ts
David Snelling c540d63b69 fix: a bare script now exits cleanly after close() — release every process keep-alive
A consumer report on the GA pair: any minimal add/find/close script hangs
forever. Root cause was FOUR ref'd keep-alives brainy never released:

1+2. The global SIGTERM/SIGINT shutdown hooks (registered once at init) were
   anonymous and never removed — a process.on signal listener holds a ref'd
   signal handle. They are now named statics, deregistered when the LAST live
   instance closes (Brainy.instances also stopped leaking closed instances);
   a later init re-registers them.
3. UnifiedCache.startFairnessMonitor() created an interval it never stored,
   cleared, or unref'd — unclearable by construction. Now stored + unref'd
   (same posture as the existing memory-pressure timer): a cache monitor must
   never keep the host process alive.
4. brainy.close() never called VirtualFileSystem.close(), stranding the VFS
   background-maintenance interval and the PathResolver maintenance interval.
   close() now shuts the VFS down.

Proof: a bare script (init/add/close, no process.exit) exits 1 ms after
close() returns; before the fix it hung until killed. 3 new lifecycle tests
pin the hook semantics (once globally, survive while other instances live,
removed on last close, re-register after).
2026-07-02 16:39:16 -07:00

79 lines
2.8 KiB
TypeScript

/**
* Global shutdown-hook lifecycle — a library must never keep its host process
* alive. init() registers process-level SIGTERM/SIGINT/beforeExit listeners
* (once, globally) whose signal handles are ref'd by Node; if they outlive the
* last brain, a bare script hangs forever after close() (consumer-reported on
* the GA pair). The contract pinned here:
*
* - first init() → exactly one listener added per signal
* - more inits → no additional listeners (registered once)
* - close() → listeners survive while OTHER instances remain live
* - last close() → listeners removed, counts back to baseline
* - re-init → hooks re-register (the once-flag resets)
*/
import { describe, it, expect } from 'vitest'
import { Brainy, NounType } from '../../src/index.js'
const SIGNALS = ['SIGTERM', 'SIGINT', 'beforeExit'] as const
function listenerCounts(): Record<string, number> {
return Object.fromEntries(SIGNALS.map((s) => [s, process.listeners(s as any).length]))
}
function makeBrain(): any {
return new Brainy({ requireSubtype: false, storage: { type: 'memory' }, plugins: [], silent: true })
}
describe('Shutdown-hook lifecycle (process must exit after the last close)', () => {
it('registers each hook once on first init and removes ALL of them on the last close', async () => {
const baseline = listenerCounts()
const brain = makeBrain()
await brain.init()
for (const s of SIGNALS) {
expect(process.listeners(s as any).length).toBe(baseline[s] + 1) // exactly one each
}
await brain.close()
expect(listenerCounts()).toEqual(baseline) // gone — the loop can drain
})
it('keeps the hooks while any other instance is live; only the LAST close deregisters', async () => {
const baseline = listenerCounts()
const first = makeBrain()
const second = makeBrain()
await first.init()
await second.init()
for (const s of SIGNALS) {
expect(process.listeners(s as any).length).toBe(baseline[s] + 1) // once globally, not per instance
}
await first.close()
for (const s of SIGNALS) {
expect(process.listeners(s as any).length).toBe(baseline[s] + 1) // second is still live
}
await second.close()
expect(listenerCounts()).toEqual(baseline)
})
it('re-registers after a full close/re-init cycle (the once-flag resets)', async () => {
const baseline = listenerCounts()
const first = makeBrain()
await first.init()
await first.close()
expect(listenerCounts()).toEqual(baseline)
const second = makeBrain()
await second.init()
const id = await second.add({ data: 'still fully functional', type: NounType.Concept })
expect(id).toBeTruthy()
for (const s of SIGNALS) {
expect(process.listeners(s as any).length).toBe(baseline[s] + 1)
}
await second.close()
expect(listenerCounts()).toEqual(baseline)
})
})