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.
This commit is contained in:
David Snelling 2026-07-02 17:26:22 -07:00
parent 2da2736ac6
commit 30eacbdfeb
8 changed files with 158 additions and 0 deletions

View file

@ -1262,6 +1262,15 @@ export class Brainy<T = any> implements BrainyInterface<T> {
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)

View file

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

View file

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

View file

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

View file

@ -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) {

View file

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

View file

@ -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<Omit<VFSConfig, 'rootEntityId'>> & { rootEntityId?: string } {