fix(shutdown): beforeExit never closes a live brain — a drained event loop is not a shutdown
Some checks failed
CI / Node 22 (push) Successful in 12m30s
CI / Node 24 (push) Successful in 12m23s
CI / Bun (latest) (push) Successful in 12m36s
CI / Integration + conformance (Node 22) (push) Failing after 17m33s

10.4.11 gave shutdown one owner and one path — close() — and wired all three
process listeners to it. That is right for SIGTERM and SIGINT. It is wrong for
'beforeExit', which Node emits whenever the event loop has no REF'd work left:
not when the process is ending, and with no signal involved. A healthy script
reaches that state routinely, because this engine unref's its idle and cadence
timers ("an idle brain costs nothing"), so a script awaiting anything those
timers drive is, for that instant, a process with no ref'd work and an open
brain.

MEASURED on the 11.1 rehearsal lane against a copy of a real store: after the
heal phase the log printed "Shutdown signal received - flushing pending
data..." and "Flushed successfully (1 instance)" with no signal ever sent, and
the script's very next add() threw "Brainy instance is not initialized: it was
closed via close(). Create a new instance." The engine had closed a live brain
out from under a running script.

The beforeExit listener now runs its own pass, which closes nothing,
deregisters nothing, releases no writer lock, and never force-exits: it runs
flush() — the engine's own non-closing durability door — on each live brain and
leaves every one of them open and usable. flush() persists derived state only
(count ledger, projections, generation counter, aggregation, entity-tree
stamp); the clean-shutdown marker is generationStore.close()'s word about
itself, reached only from close(). Running it concurrently with live writes is
the engine's ordinary steady state — noteWriteForPersistence() kicks the same
call off an unref'd timer on every busy brain — and it is single-flight, so
there is no new race. A throw is reported per instance and the pass continues:
canonical data is durable at ack via the fact log, so a failed derived-state
flush costs the next open a rebuild, never the caller their brain.

The listener is no longer self-deregistered. It does not need to be: a flush on
a clean brain schedules no I/O, so the emit after it does no event-loop work
and the process exits on its own. A one-shot listener spent on a spurious
mid-script drain would leave the genuine end-of-script drain with nothing. The
drained-loop notice is printed once per registration cycle, because a
console.log to a pipe is itself event-loop work.

exitIfSoleShutdownOwner() stays on the signal path alone, and its contract now
says so: beforeExit suppresses no default behaviour, so exiting from it would
end a live script at code 0 mid-work.

THE NAMED TRADE: a script that opens a brain and never closes it now exits with
its writer lock still on disk and no clean-shutdown marker, so its next open
overwrites a stale lock and folds the log. That is the honest cost of never
closing, and the narration names the cure. Closing a live brain to avoid it was
the worse half of the trade.

Pins: tests/integration/beforeexit-never-closes.test.ts — a script that drains
the loop with a brain open keeps a working brain (add + find succeed, the lock
is still held, the process still exits 0), the pass flushed and wrote neither
of close()'s markers, and repeated drains are idempotent. Both cases fail on
10.4.11's handler with the exact production shape ("add() after the drain
failed", "pass 1 closed the brain"). Re-run green: shutdown-single-owner,
writer-lock-clean-close, idle-costs-nothing, shutdown-hooks-lifecycle.

docs/concepts/multi-process.md no longer claims beforeExit releases the lock.
This commit is contained in:
David Snelling 2026-09-02 14:18:19 -07:00
parent 27759a1be9
commit 6baa4d7f6c
3 changed files with 450 additions and 17 deletions

View file

@ -95,8 +95,15 @@ The heartbeat interval rewrites the lock file every 10 seconds. The timer
is unref'd, so it does not keep the event loop alive on its own.
On normal shutdown the writer releases the lock in `close()`. The shutdown
hooks Brainy registers for `SIGTERM`, `SIGINT`, and `beforeExit` also
release the lock so a container restart doesn't strand the directory.
hooks Brainy registers for `SIGTERM` and `SIGINT` close every live brain by
that same `close()`, so a container restart doesn't strand the directory.
`beforeExit` is not one of them. Node emits it whenever the event loop has
no ref'd work left — a state a healthy script reaches routinely, because
Brainy's own idle and cadence timers are unref'd — and a drained event loop
is not a shutdown. That hook only persists derived state with a non-closing
`flush()`: it closes nothing, releases no lock, and leaves every brain open
and usable. If you want a shutdown, call `close()` or send `SIGTERM`.
## How to inspect a live writer

View file

@ -531,6 +531,19 @@ export class Brainy<T = any> implements BrainyInterface<T> {
private static sigintListener?: () => void
private static beforeExitListener?: () => void
/** True while the `beforeExit` pass is running its flushes. Node re-emits
* 'beforeExit' after every loop drain and that pass schedules async work, so
* a second emit can arrive on top of the first; it returns instead of
* stacking a parallel pass. NOT a one-shot: every genuine drain still gets a
* flush. See {@link registerShutdownHooks}. */
private static beforeExitFlushInFlight = false
/** Whether the drained-event-loop notice has been printed for this
* registration cycle. Printed ONCE `console.log` to a pipe is itself
* event-loop work, so narrating on every emit would keep the loop turning
* and narrate forever. Reset by {@link deregisterShutdownHooksIfIdle}. */
private static beforeExitNarrated = false
/** Poll cadence (ms) for the migration LOCK when a provider exposes no
* event-driven `whenMigrationComplete()` signal. See {@link awaitMigrationLock}. */
private static readonly MIGRATION_POLL_INTERVAL_MS = 250
@ -2130,9 +2143,11 @@ export class Brainy<T = any> implements BrainyInterface<T> {
* Critical for Cloud Run, Fargate, Lambda, and other containerized deployments.
*
* Handles:
* - SIGTERM: Graceful termination (Cloud Run, Fargate, Lambda)
* - SIGINT: Ctrl+C (development/local testing)
* - beforeExit: Node.js cleanup hook (fallback)
* - SIGTERM: Graceful termination (Cloud Run, Fargate, Lambda) CLOSES.
* - SIGINT: Ctrl+C (development/local testing) CLOSES.
* - beforeExit: the event loop drained FLUSHES, and closes NOTHING. A
* drained loop is not a shutdown; see {@link flushOnDrainedEventLoop}'s
* contract below.
*
* NOTE: Registers globally (once for all instances) to avoid MaxListenersExceededWarning
*/
@ -2229,6 +2244,106 @@ export class Brainy<T = any> implements BrainyInterface<T> {
}
}
/**
* THE DRAINED-EVENT-LOOP PATH. A DRAINED LOOP IS NOT A SHUTDOWN.
*
* Node emits `'beforeExit'` whenever the event loop has no REF'd work
* left NOT when the process is ending, and with no signal involved. A
* perfectly healthy script reaches that state routinely: this engine
* unref's its idle and cadence timers ("an idle brain costs nothing"), so
* a script awaiting anything those timers drive is, for that instant,
* a process with no ref'd work and an open brain.
*
* MEASURED on the 11.1 rehearsal lane against a copy of a real store: the
* `beforeExit` listener was wired to the SIGNAL path, so after the heal
* phase the log printed `Shutdown signal received - flushing pending
* data...` and `Flushed successfully (1 instance)` with NO signal ever
* sent, and the script's very next `add()` threw `Brainy instance is not
* initialized: it was closed via close(). Create a new instance.` The
* engine had closed a live brain out from under a running script.
*
* SO, THE LAW: this path NEVER closes, deregisters, tears down or
* force-exits anything, and never releases a writer lock. It runs
* `flush()` the engine's own non-closing durability door on each live
* brain, and leaves every one of them open and usable.
*
* WHY flush() AND NOT NOTHING. Each claim checked against the code it
* names:
* 1. IT CANNOT CLOSE ANYTHING. `flush()` `_flushSteps()` persists
* DERIVED state only: the count ledger, the metadata/graph/vector
* projections, the generation counter, aggregation state, the
* entity-tree stamp. It closes no component, deactivates no plugin,
* touches neither `initialized` nor `closed`, and never calls
* `releaseWriterLock()` the clean-shutdown marker is written by
* `generationStore.close()` alone, reached only from `close()`.
* 2. IT CANNOT RACE A LATER WRITE INTO CORRUPTION. A background flush
* concurrent with live writes is the engine's ORDINARY steady state:
* `noteWriteForPersistence()` kicks exactly this call off an unref'd
* timer on every busy brain. `flush()` is single-flight with one queued
* follow-up, and a write landing mid-flush re-sets the dirty witness,
* so its work is never lost it belongs to the next flush.
* 3. IT CANNOT SPIN. `flush()` on a clean brain returns without touching a
* provider or scheduling I/O, so the second emit does no event-loop
* work and the process exits. That is also why the listener is NOT
* self-deregistered any more: a one-shot listener spent on a spurious
* mid-script drain leaves the genuine end-of-script drain with nothing.
* 4. A FAILED FLUSH IS SURVIVABLE AND LOUD. The write path is durable at
* ack via the fact log; derived state is rebuildable. A throw is
* reported per instance and the loop continues exactly how
* `kickBackgroundFlush()` already treats the same failure.
*
* The one thing lost against a closing handler is the clean-shutdown
* marker for a script that opens a brain and never closes it: its next
* open folds the log. That is the correct trade a missing marker costs
* a recovery fold, closing a live brain costs the caller its brain and
* the narration below names the cure.
*/
const flushOnDrainedEventLoop = async () => {
// A second emit can land on top of the first (this pass schedules async
// work, the loop turns, the loop drains again). One pass at a time.
if (Brainy.beforeExitFlushInFlight) return
// Step aside for anyone whose close is running or done — the same
// ownership rule the signal path follows.
const live = [...Brainy.instances].filter(
(instance) => instance.initialized && !instance.closed && instance._closeInFlight === null
)
if (live.length === 0) return
// ONCE per registration cycle: a `console.log` to a pipe is itself
// event-loop work, so narrating on every emit would keep the loop
// turning and narrate forever.
if (!Brainy.beforeExitNarrated) {
Brainy.beforeExitNarrated = true
console.log(
`[Brainy] event loop drained with ${live.length} brain${live.length > 1 ? 's' : ''} ` +
`open — persisting derived state; NOTHING was closed. A drained loop is not a ` +
`shutdown: call close() (or send SIGTERM) when you mean one.`
)
}
Brainy.beforeExitFlushInFlight = true
try {
for (const instance of live) {
try {
await instance.flush()
} catch (error) {
// Per-instance isolation, and never fatal: canonical data is
// durable at ack, so a failed derived-state flush costs the next
// open a rebuild — it must not cost this one its brain.
console.error(
'[Brainy] flush on a drained event loop failed for one open brain ' +
'(the brain stays open and usable; derived-state persistence retries at the ' +
'next flush, and canonical data is unaffected):',
error
)
}
}
} finally {
Brainy.beforeExitFlushInFlight = false
}
}
// Graceful shutdown signals (registered once globally). The listeners are
// kept as statics so the last live instance's close() can deregister them
// — the signal handles they hold are ref'd and would otherwise keep the
@ -2254,6 +2369,14 @@ export class Brainy<T = any> implements BrainyInterface<T> {
* last brain deregisters Brainy's own listeners — so a host application's
* single remaining listener would look like `<= 1` and get force-exited
* out of its own graceful shutdown, precisely the failure above.
*
* SIGNALS ONLY NEVER `beforeExit`. The reasoning above is entirely about
* a signal Brainy has suppressed Node's default terminate behaviour for.
* `beforeExit` suppresses nothing: Node exits by itself once the loop is
* genuinely done, and the script that is still running when it fires is
* not shutting down at all. Calling this from that path would end a live
* script at exit code 0 mid-work. It is called from the two signal
* listeners below and from nowhere else.
*/
const exitIfSoleShutdownOwner = (ownersWhenSignalled: number): void => {
if (ownersWhenSignalled <= 1) {
@ -2270,18 +2393,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
await closeOnShutdown()
exitIfSoleShutdownOwner(owners)
}
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 closeOnShutdown()
}
Brainy.beforeExitListener = flushOnDrainedEventLoop
process.on('SIGTERM', Brainy.sigtermListener)
process.on('SIGINT', Brainy.sigintListener)
process.on('beforeExit', Brainy.beforeExitListener)
@ -2303,6 +2415,11 @@ export class Brainy<T = any> implements BrainyInterface<T> {
Brainy.sigtermListener = undefined
Brainy.sigintListener = undefined
Brainy.beforeExitListener = undefined
// A later re-init is a fresh cycle: it may narrate its own drained-loop
// notice, and no pass of the previous cycle can still be running (the last
// close() drained the flush chain).
Brainy.beforeExitNarrated = false
Brainy.beforeExitFlushInFlight = false
Brainy.shutdownHooksRegisteredGlobally = false
}

View file

@ -0,0 +1,309 @@
/**
* @module tests/integration/beforeexit-never-closes
* @description A DRAINED EVENT LOOP IS NOT A SHUTDOWN.
*
* MEASURED on the 11.1 rehearsal lane, against a copy of a real store. The
* `beforeExit` listener had been wired to the SIGNAL path the path whose job
* is to `close()` every live brain so after the heal phase the log printed
*
* "Shutdown signal received - flushing pending data..."
* "Flushed successfully (1 instance)"
*
* with no signal ever sent, and the script's very next `add()` threw
*
* "Brainy instance is not initialized: it was closed via close().
* Create a new instance."
*
* Node emits `'beforeExit'` whenever the event loop has no REF'd work left.
* That is not "the process is ending" it is a state a perfectly healthy
* script reaches, because this engine unref's its idle and cadence timers
* ("an idle brain costs nothing"), so a script awaiting anything those timers
* drive is, for that instant, a process with no ref'd work and an open brain.
* The engine closed a live brain out from under a running script.
*
* The contract pinned here:
* (1) `'beforeExit'` firing while a brain is open closes NOTHING: the brain
* is still open, `add()` and `find()` still work, the writer lock is
* still held, and the process still exits 0 on its own afterwards.
* (2) The pass DOES persist derived state a non-closing `flush()` ran
* and it wrote no clean-shutdown marker and no clean-close record: those
* are `close()`'s word about itself, and no close happened.
* (3) The signal path is untouched: SIGTERM still closes through `close()`
* (pinned by tests/integration/shutdown-single-owner.test.ts, re-run
* with this change).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
import { spawn } from 'node:child_process'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Brainy } from '../../src/brainy.js'
import { NounType } from '../../src/types/graphTypes.js'
const REPO_ROOT = process.cwd()
const TSX = join(REPO_ROOT, 'node_modules', '.bin', 'tsx')
const BRAINY_SRC = join(REPO_ROOT, 'src', 'brainy.ts')
function makeTempDir(prefix: string): string {
return mkdtempSync(join(tmpdir(), prefix))
}
/** The writer lock itself — present for as long as this process owns the store. */
const writerLockPath = (dir: string) => join(dir, 'locks', '_writer.lock')
/** The clean-close record — written by `releaseWriterLock()`, i.e. by close(). */
const closeRecordPath = (dir: string) => join(dir, 'locks', '_writer.close')
/**
* The generation store's clean-shutdown marker written by
* `generationStore.close()` alone, reached only from `close()`. (Raw objects
* are gzipped on disk, so both spellings are accepted.)
*/
const cleanShutdownWritten = (dir: string) =>
existsSync(join(dir, '_system', 'clean-shutdown.json.gz')) ||
existsSync(join(dir, '_system', 'clean-shutdown.json'))
/**
* Write a child script and run it under tsx to completion, collecting stdout
* and stderr and the exit code. (A file, not `tsx -e`: the eval form compiles
* to CommonJS, which has no top-level await.)
*/
function runChild(
scriptDir: string,
body: string
): Promise<{ code: number | null; out: string }> {
const scriptPath = join(scriptDir, 'child-process.mts')
writeFileSync(scriptPath, body)
// The child is an ORDINARY consumer process, so it runs the real embedding
// pipeline: this suite's deterministic-embedder switch is inherited through
// the environment, and under it `find()` self-retrieval returns nothing —
// which would make the read half of this pin vacuous. (That property is the
// deterministic embedder's, not this change's: it reproduces in a plain
// script with no 'beforeExit' involved.)
const env = { ...process.env }
delete env.BRAINY_DETERMINISTIC_EMBEDDINGS
const child = spawn(TSX, [scriptPath], {
cwd: REPO_ROOT,
stdio: ['ignore', 'pipe', 'pipe'],
env
})
let out = ''
child.stdout?.on('data', (d) => { out += String(d) })
child.stderr?.on('data', (d) => { out += String(d) })
return new Promise((resolvePromise) => {
child.on('exit', (code) => resolvePromise({ code, out }))
})
}
describe('beforeExit never closes a live brain', () => {
let dir: string
let scriptDir: string
let resultPath: string
beforeEach(() => {
dir = makeTempDir('brainy-beforeexit-')
scriptDir = makeTempDir('brainy-beforeexit-script-')
resultPath = join(scriptDir, 'result.json')
})
afterEach(() => {
for (const d of [dir, scriptDir]) {
try { rmSync(d, { recursive: true, force: true }) } catch { /* ignore */ }
}
})
it('(1)+(2) a drained event loop flushes, closes nothing, and the script keeps working', async () => {
/**
* THE DRAIN, and why the script survives it. The script awaits a promise
* that only an UNREF'd timer will resolve the shape every engine cadence
* timer has, and the reason a healthy script reaches a loop with no ref'd
* work. Node emits `'beforeExit'` there, with the brain wide open.
*
* The engine's listener runs first (registered by `init()`, before the
* script's). The script's own listener is both its witness it records
* that the emit happened, and the flush count AT that moment and its
* belt: it resolves the same promise, so the pin never depends on how many
* milliseconds the engine's pass happens to keep the loop turning.
*
* The brain is DIRTY at the drain (one add, after a settling flush), so
* the pass has real work to do and pin (2) is about a flush that ran, not
* a flush that was skipped as a no-op.
*/
const script = `
import { writeFileSync as __writeFileSync, existsSync as __existsSync } from 'node:fs'
import { join as __join } from 'node:path'
import { Brainy } from ${JSON.stringify(BRAINY_SRC)}
const DIR = ${JSON.stringify(dir)}
const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: DIR } })
await brain.init()
// Count every flush that RUNS on this brain. An own property shadows the
// prototype for every caller, including the engine's own listeners.
let flushes = 0
const flushImpl = brain.flush.bind(brain)
brain.flush = () => { flushes++; return flushImpl() }
// ...and every close ENTERED. This must still be 0 after the drain.
let closes = 0
const closeImpl = brain.close.bind(brain)
brain.close = () => { closes++; return closeImpl() }
await brain.add({ data: 'written before the drain', type: 'concept' })
await brain.flush() // settle: clean brain
await new Promise((r) => setTimeout(r, 250)) // let the cadence quiet down
await brain.add({ data: 'the write the drain must persist', type: 'concept' })
const flushesBeforeDrain = flushes
let drains = 0
let flushesAtDrain = -1
const drained = new Promise((resolve) => {
const t = setTimeout(resolve, 5)
if (typeof t.unref === 'function') t.unref()
process.on('beforeExit', () => {
drains++
if (flushesAtDrain === -1) flushesAtDrain = flushes
resolve()
})
})
await drained
// GIVE THE ENGINE'S PASS ITS FULL TURN before judging it. The signal
// path this listener used to share defers one macrotask before it
// touches an instance, so a script that resumes on the same tick as the
// emit would race past the damage and see an open brain that is about to
// be closed underneath it. Wait it out (a ref'd timer — the drain has
// already happened), then look.
await new Promise((r) => setTimeout(r, 1000))
// ---- The script is still running. The brain must still be its brain. ----
const stateAtResume = {
drains,
flushesBeforeDrain,
flushesAtDrain,
closes,
isClosed: brain.isClosed,
isClosing: brain.isClosing,
writerLockHeld: __existsSync(__join(DIR, 'locks', '_writer.lock')),
cleanCloseRecord: __existsSync(__join(DIR, 'locks', '_writer.close')),
cleanShutdownMarker:
__existsSync(__join(DIR, '_system', 'clean-shutdown.json.gz')) ||
__existsSync(__join(DIR, '_system', 'clean-shutdown.json'))
}
let addAfterDrain = null
let addError = null
try {
addAfterDrain = await brain.add({ data: 'written AFTER the drained event loop', type: 'concept' })
} catch (error) {
addError = error instanceof Error ? error.message : String(error)
}
let findHits = -1
let findError = null
try {
const results = await brain.find('written AFTER the drained event loop')
findHits = results.length
} catch (error) {
findError = error instanceof Error ? error.message : String(error)
}
__writeFileSync(
${JSON.stringify(resultPath)},
JSON.stringify({ ...stateAtResume, addAfterDrain, addError, findHits, findError, closesBeforeOurs: closes })
)
// The script ends the way a script ends: it closes its own brain, and
// the process exits on its own because nothing is left holding the loop.
await brain.close()
`
const { code, out } = await runChild(scriptDir, script)
expect(existsSync(resultPath), `child wrote no result file:\n${out}`).toBe(true)
const r = JSON.parse(readFileSync(resultPath, 'utf-8'))
// The drain really happened — this test proves nothing otherwise.
expect(r.drains, `'beforeExit' never fired:\n${out}`).toBeGreaterThanOrEqual(1)
// (1) NOTHING WAS CLOSED. This is the regression: under 10.4.11 the pass
// ran close() here and `addError` carried "it was closed via close()".
expect(r.addError, `add() after the drain failed:\n${out}`).toBeNull()
expect(r.findError, `find() after the drain failed:\n${out}`).toBeNull()
expect(r.closes, 'the engine closed the brain on a drained event loop').toBe(0)
expect(r.isClosed).toBe(false)
expect(r.isClosing).toBe(false)
expect(typeof r.addAfterDrain).toBe('string')
expect(r.findHits, `find() returned nothing:\n${out}`).toBeGreaterThanOrEqual(1)
// (1) The writer lock was never given up — a drained loop is not a handover.
expect(r.writerLockHeld, 'the writer lock was released on a drained event loop').toBe(true)
// (2) A flush RAN, and it wrote neither of close()'s markers.
expect(
r.flushesAtDrain,
`the drained-loop pass ran no flush (before=${r.flushesBeforeDrain}):\n${out}`
).toBeGreaterThan(r.flushesBeforeDrain)
expect(r.cleanShutdownMarker, 'the drained-loop flush stamped a clean-shutdown marker').toBe(false)
expect(r.cleanCloseRecord, 'the drained-loop flush wrote a clean-close record').toBe(false)
expect(out).toMatch(/All indexes flushed to disk/)
// The narration says what happened, and never claims a shutdown.
expect(out).toMatch(/event loop drained with 1 brain open/)
expect(out).toMatch(/NOTHING was closed\. A drained loop is not a shutdown/)
expect(out).not.toMatch(/Shutdown signal received/)
expect(out).not.toMatch(/Flushed successfully/)
expect(out).not.toMatch(/is not initialized/)
// (1) And the process still exits 0 on its own once the script closes up.
expect(code, `child output:\n${out}`).toBe(0)
// The store the script left behind is clean: it closed properly at the end.
expect(cleanShutdownWritten(dir), 'the script\'s own close() wrote no marker').toBe(true)
expect(existsSync(closeRecordPath(dir)), 'the script\'s own close() left no clean-close record').toBe(true)
expect(existsSync(writerLockPath(dir)), 'the writer lock outlived close()').toBe(false)
}, 300_000)
it('(2) the pass is repeatable and idempotent: a second drain closes nothing either', async () => {
// In-process, so the assertions are on the object itself rather than on a
// report: 'beforeExit' is an ordinary event, and emitting it twice must
// leave the brain exactly as usable as it was.
const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
await brain.init()
const flushed: Promise<void>[] = []
const flushImpl = brain.flush.bind(brain)
;(brain as unknown as { flush: () => Promise<void> }).flush = () => {
const p = flushImpl()
flushed.push(p)
return p
}
await brain.add({ data: 'a write the drained loop must persist', type: NounType.Concept })
for (const pass of [1, 2]) {
const before = flushed.length
process.emit('beforeExit', 0)
await Promise.all(flushed.slice(before).map((p) => p.catch(() => {})))
// Let the pass's own `finally` run (it settles a microtask after ours),
// so the next emit is not turned away by the in-flight guard.
await new Promise((r) => setTimeout(r, 50))
expect(brain.isClosed, `pass ${pass} closed the brain`).toBe(false)
expect(brain.isClosing, `pass ${pass} started a close`).toBe(false)
expect(existsSync(writerLockPath(dir)), `pass ${pass} released the writer lock`).toBe(true)
expect(existsSync(closeRecordPath(dir)), `pass ${pass} wrote a clean-close record`).toBe(false)
expect(cleanShutdownWritten(dir), `pass ${pass} stamped a clean-shutdown marker`).toBe(false)
// Still a working brain, after every pass.
const id = await brain.add({ data: `still writable after drain ${pass}`, type: NounType.Concept })
expect(id).toBeTruthy()
}
// The first pass had a dirty brain and flushed it; the second found it
// clean and cost nothing. Either way, neither closed anything.
expect(flushed.length).toBeGreaterThanOrEqual(2)
await brain.close()
expect(brain.isClosed).toBe(true)
expect(cleanShutdownWritten(dir)).toBe(true)
}, 300_000)
})