@blamejs/core 0.13.39 → 0.13.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/lib/db.js +16 -4
- package/lib/redis-client.js +14 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.13.x
|
|
10
10
|
|
|
11
|
+
- v0.13.40 (2026-05-29) — **Redis client stops leaking a socket and blocking exit after close; DB exit-handler registers once.** Two handle-lifecycle fixes. The Redis client's reconnect backoff used an untracked, non-unref'd timer: during a backoff window it alone could keep the event loop alive (a process that won't exit), and a reconnect scheduled before close() fired afterward and opened a fresh socket because the connect path didn't re-check the closing flag. The timer is now tracked, unref'd, cancelled in close(), and the connect path refuses to re-open once closing. Separately, the encrypted database registered its process-exit final-flush handler on every init(), so repeated init/close cycles (long test runs, hot reload) accumulated 'exit' listeners toward the MaxListenersExceeded warning; it now registers once for the process lifetime. **Fixed:** *Redis client cancels its reconnect timer on close and won't re-open a closed connection* — The reconnect backoff scheduled `setTimeout(reconnect, delay)` without keeping a handle, without `unref()`, and the reconnect path checked only `connected`/`connecting` — not `closing`. So a backoff window could by itself hold the process open (it won't exit), and a reconnect scheduled before `close()` would fire afterward and open a fresh socket with listeners — a leak after explicit close. The timer is now tracked and `unref()`'d (a backoff no longer blocks exit), cancelled in `close()`, and the connect path returns early once closing so no socket is opened after close. · *Encrypted DB registers its process-exit flush handler once, not per init()* — `b.db.init()` in encrypted mode added a `process.on("exit")` final-flush handler on every call. Across repeated init/close cycles — long test suites, hot reload, embedded re-inits — these accumulated and tripped Node's MaxListenersExceeded warning (and grew memory slightly). The handler is now registered once for the process lifetime, guarded by a module flag, and still flushes whichever encrypted DB is open at exit time.
|
|
12
|
+
|
|
11
13
|
- v0.13.39 (2026-05-29) — **Dual-control approvals are atomic — no quorum bypass or double-consume under concurrency.** The dual-control approval store read a grant, mutated it in memory, and wrote it back with an await in between — a non-atomic read-modify-write. Under concurrent calls (two approvals, or a retried one) each could act on a stale snapshot: the same approver could be appended twice and reach the M-of-N quorum with a single human, or a single-use grant could be consumed twice. approve / consume / revoke / cancel now commit through a new atomic cache.update primitive, so the check and the mutation are one indivisible step. The new b.cache.update is available to application code too — the memory backend is atomic by single-thread, and the cluster backend uses a transaction with compare-and-set so a concurrent writer on another node cannot lose an update. **Added:** *b.cache.update(key, mutatorFn, opts?) — atomic read-modify-write* — Reads the current value, calls `mutatorFn(current | null)`, and commits the result in one operation so a concurrent writer cannot clobber the change — the lost-update race that makes a plain `get` → mutate → `set` unsafe for counters, sets, and quorum state. The memory backend is atomic by single-thread; the cluster backend runs a transaction with a compare-and-set (and retries on contention) so the guarantee holds across nodes. `mutatorFn` returns `{ value }` to commit, `{ abort: data }` to leave the entry untouched and surface `data`, or `{ delete: true }` to remove it; the call resolves to `{ updated, value }`, `{ updated, deleted }`, or `{ aborted }`. **Security:** *Dual-control quorum and single-use guarantees hold under concurrent approvals* — `b.dualControl` persisted each grant through a cache read → in-memory mutate → write-back. Because the read and the write were separate awaited steps, two concurrent `approve` calls (or a retried one behind a load balancer) could each read the same pre-approval snapshot, so the duplicate-approver guard passed twice and the same approver was counted toward the M-of-N quorum twice — reaching quorum with one human. The same shape let two concurrent `consume` calls each see an unconsumed grant and both run the destructive operation. `approve` / `consume` / `revoke` / `cancel` now perform the check-and-mutate atomically via `cache.update`, so exactly one concurrent caller wins each transition.
|
|
12
14
|
|
|
13
15
|
- v0.13.38 (2026-05-29) — **Atomic cache tag invalidation, and a clusterStorage.transaction primitive for multi-statement framework writes.** The cluster cache stored a value and its tag index with separate statements, so two concurrent writes to the same key could interleave their tag updates and leave the index out of step with the value — a later tag-based invalidation would then miss the key, letting a stale (possibly authorization-bearing) value survive a wipe. The value and tag writes now commit as one atomic unit. The enabling piece is a new b.clusterStorage.transaction primitive that runs a multi-statement read-modify-write all-or-nothing against the active backend — the external DB's pooled transaction in cluster mode, and a serialized transaction on the shared SQLite connection in single-node mode (so no concurrent statement can interleave into an open transaction). **Added:** *b.clusterStorage.transaction(fn) — atomic multi-statement framework-state writes* — Runs `fn` inside one transaction against the active backend so a multi-statement read-modify-write commits all-or-nothing. `fn` receives a transaction handle with the same `execute` / `executeOne` / `executeAll` surface, scoped to the open transaction. Cluster mode uses the external DB's pooled transaction (with its deadlock retry); single-node mode serializes against other transactions and against `execute` on the shared SQLite connection, so a concurrent statement cannot interleave into an open transaction. Use the handle's methods inside `fn` — calling the module-level `execute` from within `fn` would wait on the very transaction it is running. **Fixed:** *Cluster cache tag invalidation can no longer miss a key under concurrent writes* — The cluster cache wrote a value (`INSERT ... ON CONFLICT DO UPDATE`) and then rewrote its tag index (`DELETE` prior tags, `INSERT` new ones) as separate statements. Two concurrent `set()`s on the same key could interleave those tag statements, leaving the tag index inconsistent with the value — so a later `invalidateTag` could miss the key and a stale value would survive the wipe. The value and tag writes now run inside a single `clusterStorage.transaction`, so a concurrent writer observes either the whole prior state or the whole new state, never a mix.
|
package/lib/db.js
CHANGED
|
@@ -142,6 +142,12 @@ var storageProbeTimer = null; // periodic free-space probe handle
|
|
|
142
142
|
var writesRefused = false; // true when free space < minFreeBytes
|
|
143
143
|
var minFreeBytes = 0; // refuse growth writes below this (0 = guard off)
|
|
144
144
|
var statfsProbe = null; // free-space reader (fs.statfsSync; injectable for tests)
|
|
145
|
+
// The process-exit final-flush handler is registered ONCE at first
|
|
146
|
+
// encrypted init. Re-registering per init() leaked an 'exit' listener on
|
|
147
|
+
// every init/close cycle (MaxListenersExceeded in long test runs / hot
|
|
148
|
+
// reload); the flag makes it idempotent. The handler reads live module
|
|
149
|
+
// state at exit time, so a later re-init is still covered.
|
|
150
|
+
var _exitHandlerRegistered = false;
|
|
145
151
|
var dataDir = null;
|
|
146
152
|
var initialized = false;
|
|
147
153
|
var dataResidency = null; // operator's declared region config (validated by storage backends)
|
|
@@ -1436,10 +1442,16 @@ async function init(opts) {
|
|
|
1436
1442
|
|
|
1437
1443
|
// Final encrypt on process exit. We don't try to unlink the plaintext
|
|
1438
1444
|
// here — the SQLite handle may still be open, and the OS reclaims tmpfs
|
|
1439
|
-
// on reboot anyway. close() does the orderly shutdown.
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1445
|
+
// on reboot anyway. close() does the orderly shutdown. Registered ONCE
|
|
1446
|
+
// (guarded by the module flag) — re-registering per init() leaked an
|
|
1447
|
+
// 'exit' listener on every init/close cycle. The handler reads live
|
|
1448
|
+
// module state, so it still flushes whatever DB is open at exit.
|
|
1449
|
+
if (!_exitHandlerRegistered) {
|
|
1450
|
+
_exitHandlerRegistered = true;
|
|
1451
|
+
process.on("exit", function () {
|
|
1452
|
+
try { if (atRest === "encrypted") encryptToDisk(); } catch (_e) { /* exit handler — silent */ }
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1443
1455
|
}
|
|
1444
1456
|
|
|
1445
1457
|
log("ready (mode: " + atRest + ", path: " + dbPath + ")");
|
package/lib/redis-client.js
CHANGED
|
@@ -182,6 +182,11 @@ function create(opts) {
|
|
|
182
182
|
var connected = false;
|
|
183
183
|
var connecting = false;
|
|
184
184
|
var closing = false;
|
|
185
|
+
// Tracked + unref'd reconnect timer. Tracked so close() can cancel a
|
|
186
|
+
// pending backoff (otherwise a reconnect scheduled before close fires
|
|
187
|
+
// after it and opens a fresh socket); unref'd so a backoff window doesn't
|
|
188
|
+
// by itself keep the event loop alive (the process-won't-exit class).
|
|
189
|
+
var reconnectTimer = null;
|
|
185
190
|
var rxBuffer = Buffer.alloc(0);
|
|
186
191
|
// FIFO of in-flight commands awaiting a response
|
|
187
192
|
var pending = [];
|
|
@@ -210,7 +215,11 @@ function create(opts) {
|
|
|
210
215
|
}
|
|
211
216
|
reconnectAttempt++;
|
|
212
217
|
var delay = Math.min(C.TIME.seconds(30), 100 * Math.pow(2, reconnectAttempt - 1));
|
|
213
|
-
|
|
218
|
+
reconnectTimer = setTimeout(function () {
|
|
219
|
+
reconnectTimer = null;
|
|
220
|
+
_connect().catch(function () { /* will reschedule */ });
|
|
221
|
+
}, delay);
|
|
222
|
+
if (typeof reconnectTimer.unref === "function") reconnectTimer.unref();
|
|
214
223
|
}
|
|
215
224
|
|
|
216
225
|
function _drainPending(err) {
|
|
@@ -290,6 +299,9 @@ function create(opts) {
|
|
|
290
299
|
}
|
|
291
300
|
|
|
292
301
|
async function _connect() {
|
|
302
|
+
// A reconnect timer scheduled before close() can still fire afterward;
|
|
303
|
+
// refuse to re-open once closing so it doesn't leak a fresh socket.
|
|
304
|
+
if (closing) return;
|
|
293
305
|
if (connected) return;
|
|
294
306
|
if (connecting) {
|
|
295
307
|
// Wait until current connect attempt resolves
|
|
@@ -423,6 +435,7 @@ function create(opts) {
|
|
|
423
435
|
|
|
424
436
|
async function close() {
|
|
425
437
|
closing = true;
|
|
438
|
+
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
426
439
|
var err = _err("CLOSED", "redis client closed");
|
|
427
440
|
_drainPending(err);
|
|
428
441
|
if (socket) {
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:949239a7-6f00-4f14-9e96-43c3b5935fb9",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-29T17:
|
|
8
|
+
"timestamp": "2026-05-29T17:53:59.118Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.13.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.13.40",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.13.
|
|
25
|
+
"version": "0.13.40",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.13.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.13.40",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.13.
|
|
57
|
+
"ref": "@blamejs/core@0.13.40",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|