Compare commits
No commits in common. "a2ea21b330d49933b70cd9fed1c7fb46afb89cb8" and "1882532cb711432bcf982cd0cd5fe2049ce8dc29" have entirely different histories.
a2ea21b330
...
1882532cb7
3 changed files with 44 additions and 135 deletions
|
|
@ -337,36 +337,6 @@ function git(args, cwd) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the git identity for the wall commit from the repository the rail
|
||||
* is actually running in — the developer's own checkout (`process.cwd()`;
|
||||
* `release.sh` invokes this script from the repo root with no `cd`), via
|
||||
* git's normal config precedence (repo-local, then global, then system).
|
||||
* Never guessed and never left to git's own "who are you?" prompt: a host
|
||||
* with no configured identity anywhere (a bare CI box, say) must refuse
|
||||
* loudly rather than have git manufacture a placeholder identity or hang.
|
||||
* @returns {{name: string, email: string}}
|
||||
*/
|
||||
function resolveWallCommitIdentity() {
|
||||
const repo = process.cwd()
|
||||
let name = ''
|
||||
let email = ''
|
||||
try {
|
||||
name = git(['config', 'user.name'], repo)
|
||||
} catch {
|
||||
name = ''
|
||||
}
|
||||
try {
|
||||
email = git(['config', 'user.email'], repo)
|
||||
} catch {
|
||||
email = ''
|
||||
}
|
||||
if (!name || !email) {
|
||||
fail('no git identity for the wall commit — set user.name/user.email')
|
||||
}
|
||||
return { name, email }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a clean, up-to-date local clone of the releases repo at
|
||||
* `cacheDir`, checked out on `main` — cloning fresh if `cacheDir` has no
|
||||
|
|
@ -453,14 +423,9 @@ function publishEntry(entry, product, remote, cacheDir) {
|
|||
return
|
||||
}
|
||||
|
||||
const identity = resolveWallCommitIdentity()
|
||||
|
||||
try {
|
||||
git(['add', `${product}.json`], cacheDir)
|
||||
git(
|
||||
['-c', `user.name=${identity.name}`, '-c', `user.email=${identity.email}`, 'commit', '-m', `chore(wall): ${product} ${entry.version}`],
|
||||
cacheDir,
|
||||
)
|
||||
git(['commit', '-m', `chore(wall): ${product} ${entry.version}`], cacheDir)
|
||||
} catch (err) {
|
||||
fail(`cannot commit the wall entry in "${cacheDir}" — ${/** @type {Error} */ (err).message}\n cure: inspect "${cacheDir}" by hand and re-run once its git state is clean`)
|
||||
}
|
||||
|
|
|
|||
134
src/brainy.ts
134
src/brainy.ts
|
|
@ -544,23 +544,6 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
* and narrate forever. Reset by {@link deregisterShutdownHooksIfIdle}. */
|
||||
private static beforeExitNarrated = false
|
||||
|
||||
/** True for the entire duration of ONE `closeOnShutdown()` run (the
|
||||
* signal-path handler in {@link registerShutdownHooks}) — from before it
|
||||
* starts closing instances until after it has decided whether to exit.
|
||||
* THE RACE THIS CLOSES: closing the LAST live instance calls
|
||||
* `close()` → `deregisterShutdownHooksIfIdle()` synchronously, which
|
||||
* removes `Brainy.sigtermListener` from `process` — while `closeOnShutdown`
|
||||
* (that very listener's OWN still-running invocation) hasn't yet reached
|
||||
* `exitIfSoleShutdownOwner()`'s `process.exit(0)`. In that window Node has
|
||||
* NO registered SIGTERM listener, so a second/concurrent delivery of the
|
||||
* same signal (a raced re-send, common on a loaded host) falls through to
|
||||
* Node's default disposition and kills the process outright — the
|
||||
* clean-shutdown work already finished, but the process never reports the
|
||||
* 0 it earned. `deregisterShutdownHooksIfIdle()` checks this flag and
|
||||
* defers; `closeOnShutdown()`'s `finally` re-runs the deregistration check
|
||||
* once it is done, so the listener never actually leaks past its use. */
|
||||
private static shutdownSignalHandlerActive = 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
|
||||
|
|
@ -2213,74 +2196,51 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
*/
|
||||
const closeOnShutdown = async () => {
|
||||
console.log('Shutdown signal received - flushing pending data...')
|
||||
// HOLD THE LISTENER FOR THE WHOLE RUN. Closing the LAST live instance
|
||||
// below calls close() → deregisterShutdownHooksIfIdle(), which removes
|
||||
// Brainy's own SIGTERM/SIGINT listeners from `process` — synchronously,
|
||||
// before THIS invocation has reached exitIfSoleShutdownOwner()'s
|
||||
// process.exit(0). Left alone, that opens a window with no registered
|
||||
// listener for the signal at all, so a second/concurrent delivery of
|
||||
// the same signal (a raced re-send — not rare on a loaded host) falls
|
||||
// through to Node's default disposition and kills the process outright
|
||||
// AFTER the clean-shutdown work already finished, reporting a signal
|
||||
// kill instead of the 0 the shutdown earned. Setting this flag makes
|
||||
// deregisterShutdownHooksIfIdle() defer; the `finally` below re-checks
|
||||
// it once this run is fully done — closeOnShutdown, not a nested
|
||||
// close(), owns exactly when the listener actually comes off.
|
||||
Brainy.shutdownSignalHandlerActive = true
|
||||
try {
|
||||
// DEFER ONE MACROTASK. A host application registers its own listener
|
||||
// on the same signal, and Node runs listeners in registration order —
|
||||
// ours is usually first, because the brain was opened before the
|
||||
// host wired its shutdown. Yielding once lets every other listener
|
||||
// for this signal run its synchronous prologue, so a host that calls
|
||||
// close() gets to be the owner. It is only a courtesy, never the
|
||||
// safety: close()'s own single-flight gate is what makes a lost race
|
||||
// harmless.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
// DEFER ONE MACROTASK. A host application registers its own listener on
|
||||
// the same signal, and Node runs listeners in registration order — ours
|
||||
// is usually first, because the brain was opened before the host wired
|
||||
// its shutdown. Yielding once lets every other listener for this signal
|
||||
// run its synchronous prologue, so a host that calls close() gets to be
|
||||
// the owner. It is only a courtesy, never the safety: close()'s own
|
||||
// single-flight gate is what makes a lost race harmless.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
|
||||
let closedCount = 0
|
||||
let deferredCount = 0
|
||||
let failedCount = 0
|
||||
// Snapshot: close() splices Brainy.instances while we iterate.
|
||||
for (const instance of [...Brainy.instances]) {
|
||||
if (!instance.initialized) continue
|
||||
// SOMEONE ELSE OWNS THIS ONE. Not a flush, not a lock release, not a
|
||||
// component close — nothing. Touching a brain whose close is running
|
||||
// is the whole defect this handler was rewritten for.
|
||||
if (instance.closed || instance._closeInFlight !== null) {
|
||||
deferredCount++
|
||||
continue
|
||||
}
|
||||
try {
|
||||
// Law 1: this try/catch is the isolation — the loop continues.
|
||||
await instance.close()
|
||||
closedCount++
|
||||
} catch (error) {
|
||||
failedCount++
|
||||
console.error('Failed to close one Brainy instance on shutdown:', error)
|
||||
}
|
||||
let closedCount = 0
|
||||
let deferredCount = 0
|
||||
let failedCount = 0
|
||||
// Snapshot: close() splices Brainy.instances while we iterate.
|
||||
for (const instance of [...Brainy.instances]) {
|
||||
if (!instance.initialized) continue
|
||||
// SOMEONE ELSE OWNS THIS ONE. Not a flush, not a lock release, not a
|
||||
// component close — nothing. Touching a brain whose close is running
|
||||
// is the whole defect this handler was rewritten for.
|
||||
if (instance.closed || instance._closeInFlight !== null) {
|
||||
deferredCount++
|
||||
continue
|
||||
}
|
||||
if (closedCount > 0) {
|
||||
console.log(`Flushed successfully (${closedCount} instance${closedCount > 1 ? 's' : ''})`)
|
||||
try {
|
||||
// Law 1: this try/catch is the isolation — the loop continues.
|
||||
await instance.close()
|
||||
closedCount++
|
||||
} catch (error) {
|
||||
failedCount++
|
||||
console.error('Failed to close one Brainy instance on shutdown:', error)
|
||||
}
|
||||
if (deferredCount > 0) {
|
||||
console.log(
|
||||
`${deferredCount} Brainy instance${deferredCount > 1 ? 's are' : ' is'} already ` +
|
||||
`closing — left to the caller that owns that close.`
|
||||
)
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
console.error(
|
||||
`${failedCount} Brainy instance${failedCount > 1 ? 's' : ''} did not complete shutdown — ` +
|
||||
`their writer locks were released, but their next open will run crash recovery.`
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
// Release the hold and run the deferred check ourselves — the last
|
||||
// close() above may have found the flag set and skipped its own
|
||||
// deregistration, so nobody else will do this if we don't.
|
||||
Brainy.shutdownSignalHandlerActive = false
|
||||
Brainy.deregisterShutdownHooksIfIdle()
|
||||
}
|
||||
if (closedCount > 0) {
|
||||
console.log(`Flushed successfully (${closedCount} instance${closedCount > 1 ? 's' : ''})`)
|
||||
}
|
||||
if (deferredCount > 0) {
|
||||
console.log(
|
||||
`${deferredCount} Brainy instance${deferredCount > 1 ? 's are' : ' is'} already ` +
|
||||
`closing — left to the caller that owns that close.`
|
||||
)
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
console.error(
|
||||
`${failedCount} Brainy instance${failedCount > 1 ? 's' : ''} did not complete shutdown — ` +
|
||||
`their writer locks were released, but their next open will run crash recovery.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2444,17 +2404,9 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
* script that closed every brain exits on its own — a library must never
|
||||
* keep its host process alive. Re-initializing later re-registers them
|
||||
* (the `shutdownHooksRegisteredGlobally` flag resets here).
|
||||
*
|
||||
* Deferred (not skipped — {@link closeOnShutdown}'s `finally` always
|
||||
* re-checks) while a signal-path shutdown is actively running: that
|
||||
* handler's OWN still-in-flight invocation is `Brainy.sigtermListener`, and
|
||||
* removing it out from under itself — which closing the LAST instance here
|
||||
* would otherwise do, synchronously, mid-run — would leave `process` with
|
||||
* no listener for the signal for the remainder of that run. See
|
||||
* {@link shutdownSignalHandlerActive}'s doc for the exact race this closes.
|
||||
*/
|
||||
private static deregisterShutdownHooksIfIdle(): void {
|
||||
if (Brainy.instances.length > 0 || !Brainy.shutdownHooksRegisteredGlobally || Brainy.shutdownSignalHandlerActive) {
|
||||
if (Brainy.instances.length > 0 || !Brainy.shutdownHooksRegisteredGlobally) {
|
||||
return
|
||||
}
|
||||
if (Brainy.sigtermListener) process.off('SIGTERM', Brainy.sigtermListener)
|
||||
|
|
|
|||
|
|
@ -104,14 +104,6 @@ let cacheDir: string
|
|||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'wall-entry-test-'))
|
||||
// wall-entry.mjs is run with this dir as its cwd, standing in for the real
|
||||
// developer checkout it reads its commit identity from (process.cwd()) —
|
||||
// give it a repo-local identity the same way seedRemote gives one to the
|
||||
// seed clone, so the suite is deterministic on a host with no global git
|
||||
// config (a bare CI box) as much as one with a developer's own.
|
||||
execFileSync('git', ['init', '-q', dir])
|
||||
git(['config', 'user.name', 'Wall Entry Test'], dir)
|
||||
git(['config', 'user.email', 'wall-entry-test@example.com'], dir)
|
||||
remoteDir = initBareRemote()
|
||||
cacheDir = join(mkdtempSync(join(tmpdir(), 'wall-cache-')), 'soulcraft-releases')
|
||||
})
|
||||
|
|
|
|||
Reference in a new issue