@blamejs/blamejs-shop 0.4.91 → 0.4.93

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/lib/asset-manifest.json +1 -1
  3. package/lib/security-middleware.js +21 -3
  4. package/lib/vendor/MANIFEST.json +49 -41
  5. package/lib/vendor/blamejs/CHANGELOG.md +6 -0
  6. package/lib/vendor/blamejs/SECURITY.md +1 -0
  7. package/lib/vendor/blamejs/api-snapshot.json +208 -2
  8. package/lib/vendor/blamejs/examples/wiki/test/e2e.js +7 -4
  9. package/lib/vendor/blamejs/examples/wiki/test/integration.js +15 -12
  10. package/lib/vendor/blamejs/index.js +2 -0
  11. package/lib/vendor/blamejs/lib/audit-sign.js +34 -1
  12. package/lib/vendor/blamejs/lib/backup/manifest.js +191 -44
  13. package/lib/vendor/blamejs/lib/codepoint-class.js +284 -77
  14. package/lib/vendor/blamejs/lib/framework-error.js +14 -0
  15. package/lib/vendor/blamejs/lib/fsm.js +80 -24
  16. package/lib/vendor/blamejs/lib/log.js +32 -0
  17. package/lib/vendor/blamejs/lib/middleware/rate-limit.js +18 -2
  18. package/lib/vendor/blamejs/lib/middleware/request-id.js +24 -4
  19. package/lib/vendor/blamejs/lib/request-helpers.js +50 -0
  20. package/lib/vendor/blamejs/lib/safe-path.js +24 -10
  21. package/lib/vendor/blamejs/lib/sql.js +133 -0
  22. package/lib/vendor/blamejs/lib/totp.js +98 -33
  23. package/lib/vendor/blamejs/lib/ws-client.js +39 -28
  24. package/lib/vendor/blamejs/package.json +1 -1
  25. package/lib/vendor/blamejs/release-notes/v0.15.21.json +51 -0
  26. package/lib/vendor/blamejs/release-notes/v0.15.22.json +18 -0
  27. package/lib/vendor/blamejs/release-notes/v0.15.23.json +22 -0
  28. package/lib/vendor/blamejs/test/00-primitives.js +80 -0
  29. package/lib/vendor/blamejs/test/_smoke-worker.js +81 -0
  30. package/lib/vendor/blamejs/test/integration/federation-auth.test.js +7 -4
  31. package/lib/vendor/blamejs/test/integration/mail-crypto-smime.test.js +7 -4
  32. package/lib/vendor/blamejs/test/layer-0-primitives/backup-manifest-signature.test.js +91 -0
  33. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +65 -0
  34. package/lib/vendor/blamejs/test/layer-0-primitives/codepoint-class.test.js +58 -0
  35. package/lib/vendor/blamejs/test/layer-0-primitives/defineguard-default-gate-posture-caps.test.js +5 -2
  36. package/lib/vendor/blamejs/test/layer-0-primitives/dpop-middleware-replaystore-required.test.js +9 -1
  37. package/lib/vendor/blamejs/test/layer-0-primitives/fsm.test.js +99 -0
  38. package/lib/vendor/blamejs/test/layer-0-primitives/money.test.js +30 -0
  39. package/lib/vendor/blamejs/test/layer-0-primitives/otlp-attr-redaction.test.js +9 -6
  40. package/lib/vendor/blamejs/test/layer-0-primitives/rate-limit-xff-spoofing.test.js +36 -0
  41. package/lib/vendor/blamejs/test/layer-0-primitives/request-helpers.test.js +33 -0
  42. package/lib/vendor/blamejs/test/layer-0-primitives/request-id-async-context.test.js +117 -0
  43. package/lib/vendor/blamejs/test/layer-0-primitives/safe-path.test.js +64 -0
  44. package/lib/vendor/blamejs/test/layer-0-primitives/sql.test.js +96 -0
  45. package/lib/vendor/blamejs/test/layer-0-primitives/ws-client.test.js +55 -0
  46. package/lib/vendor/blamejs/test/smoke.js +93 -10
  47. package/package.json +1 -1
@@ -32,6 +32,8 @@ async function run() {
32
32
  check("b.sql.delete", typeof b.sql.delete === "function");
33
33
  check("b.sql.upsert", typeof b.sql.upsert === "function");
34
34
  check("b.sql.insertSelectWhere", typeof b.sql.insertSelectWhere === "function");
35
+ check("b.sql.guardedUpdate", typeof b.sql.guardedUpdate === "function");
36
+ check("b.sql.casWon", typeof b.sql.casWon === "function");
35
37
  check("b.sql.table", typeof b.sql.table === "function");
36
38
  check("b.sql.createTable", typeof b.sql.createTable === "function");
37
39
  check("b.sql.createIndex", typeof b.sql.createIndex === "function");
@@ -709,6 +711,100 @@ async function run() {
709
711
  check("insertSelectWhere toExternalSql postgres $N",
710
712
  ext.sql.indexOf("$1") !== -1 && ext.sql.indexOf("$2") !== -1 &&
711
713
  ext.sql.indexOf("$3") !== -1 && ext.sql.indexOf("?") === -1);
714
+
715
+ // ==== guardedUpdate (compare-and-swap UPDATE, #344) ====
716
+ // The conditional-UPDATE sibling of insertSelectWhere: advance a status /
717
+ // version ONLY when the row is still in the expected value, so two racing
718
+ // transitions on an autocommit-only substrate cannot both win.
719
+
720
+ // Canonical CAS: identity where() + a guardWhere() fence ANDed into one WHERE,
721
+ // params bound set -> identity -> guard in order.
722
+ var cas = sql.guardedUpdate("orders")
723
+ .set({ status: "shipped" }).where("id", 7).guardWhere("status", "paid").toSql();
724
+ check("guardedUpdate emits UPDATE...SET...WHERE id AND guard",
725
+ cas.sql === 'UPDATE orders SET "status" = ? WHERE "id" = ? AND "status" = ?' &&
726
+ cas.params.length === 3 && cas.params[0] === "shipped" &&
727
+ cas.params[1] === 7 && cas.params[2] === "paid");
728
+
729
+ // guardWhereOp for a non-equality fence (optimistic version / balance debit).
730
+ var casOp = sql.guardedUpdate("wallets")
731
+ .set({ balance: 90 }).where("id", 1).guardWhereOp("balance", ">=", 10).toSql();
732
+ check("guardedUpdate guardWhereOp renders a >= fence",
733
+ casOp.sql.indexOf('"balance" >= ?') !== -1 && casOp.params[casOp.params.length - 1] === 10);
734
+
735
+ // A null-valued fence becomes IS NULL (col = NULL never matches), no null param.
736
+ var casNull = sql.guardedUpdate("orders")
737
+ .set({ locked_by: "node-a" }).where("id", 1).guardWhere("locked_by", null).toSql();
738
+ check("guardedUpdate null fence renders IS NULL, binds no null",
739
+ casNull.sql.indexOf('"locked_by" IS NULL') !== -1 && casNull.params.indexOf(null) === -1);
740
+
741
+ // An UNDEFINED fence is refused (not silently collapsed to IS NULL) — an
742
+ // omitted/unset expected value would turn a CAS into "match NULL-state rows"
743
+ // and update the wrong rows. Only an EXPLICIT null means IS NULL.
744
+ rejects("guardedUpdate guardWhere(undefined) is refused", function () {
745
+ return sql.guardedUpdate("orders").set({ status: "x" }).where("id", 1)
746
+ .guardWhere("status", undefined).toSql();
747
+ }, "sql-builder/bad-guard-value");
748
+
749
+ // mysql backtick quoting + postgres $N positional carry through unchanged.
750
+ var casMy = sql.guardedUpdate("orders", { dialect: "mysql" })
751
+ .set({ status: "shipped" }).where("id", 7).guardWhere("status", "paid").toSql();
752
+ check("guardedUpdate mysql backtick quoting",
753
+ casMy.sql === "UPDATE orders SET `status` = ? WHERE `id` = ? AND `status` = ?");
754
+ var casPg = sql.guardedUpdate("orders", { dialect: "postgres" })
755
+ .set({ status: "shipped" }).where("id", 7).guardWhere("status", "paid").toExternalSql("postgres");
756
+ check("guardedUpdate toExternalSql postgres $N",
757
+ casPg.sql.indexOf("$1") !== -1 && casPg.sql.indexOf("$3") !== -1 && casPg.sql.indexOf("?") === -1);
758
+
759
+ // Refuses to render without a fence — an unguarded guardedUpdate is just a
760
+ // plain update and almost always a CAS-forgotten bug.
761
+ rejects("guardedUpdate without a guardWhere throws", function () {
762
+ return sql.guardedUpdate("orders").set({ status: "x" }).where("id", 1).toSql();
763
+ }, "sql-builder/no-guard");
764
+ // set() is still required.
765
+ rejects("guardedUpdate without set throws", function () {
766
+ return sql.guardedUpdate("orders").where("id", 1).guardWhere("status", "paid").toSql();
767
+ }, "sql-builder/empty-set");
768
+
769
+ // casWon: own the rowCount -> won/lost mapping + cross-adapter field names.
770
+ check("casWon: rowCount=1 -> won", sql.casWon({ rowCount: 1 }).won === true);
771
+ check("casWon: rowCount=0 -> lost", sql.casWon({ rowCount: 0 }).won === false);
772
+ check("casWon: rowCount=2 -> lost, count kept",
773
+ sql.casWon({ rowCount: 2 }).won === false && sql.casWon({ rowCount: 2 }).rowCount === 2);
774
+ check("casWon: raw sqlite changes field", sql.casWon({ changes: 1 }).won === true);
775
+ check("casWon: raw mysql affectedRows field", sql.casWon({ affectedRows: 1 }).won === true);
776
+ rejects("casWon: indeterminate result throws (no phantom win)", function () {
777
+ return sql.casWon({ foo: 1 });
778
+ }, "sql-builder/no-row-count");
779
+ rejects("casWon: non-object throws", function () {
780
+ return sql.casWon(null);
781
+ }, "sql-builder/bad-cas-result");
782
+
783
+ // Real-engine execution: build the CAS with b.sql, run it against an actual
784
+ // SQLite engine (node:sqlite), and prove the race contract end to end — the
785
+ // first transition wins (rowCount 1, casWon true), a second racer on the now-
786
+ // advanced row loses (rowCount 0, casWon false). This is the consumer path the
787
+ // issue is about (cross-instance-safe transition on a single-statement
788
+ // substrate); the same standard SQL runs identically on Postgres / MySQL.
789
+ var nodeSqlite = null;
790
+ try { nodeSqlite = require("node:sqlite"); } catch (_e) { nodeSqlite = null; }
791
+ if (nodeSqlite && typeof nodeSqlite.DatabaseSync === "function") {
792
+ var edb = new nodeSqlite.DatabaseSync(":memory:");
793
+ edb.exec("CREATE TABLE orders (id INTEGER PRIMARY KEY, status TEXT)");
794
+ edb.prepare("INSERT INTO orders (id, status) VALUES (?, ?)").run(7, "paid");
795
+ var casQ = sql.guardedUpdate("orders")
796
+ .set({ status: "shipped" }).where("id", 7).guardWhere("status", "paid").toSql();
797
+ var wonStmt = edb.prepare(casQ.sql);
798
+ var won = wonStmt.run.apply(wonStmt, casQ.params);
799
+ check("guardedUpdate live: winner casWon true (rowCount 1)", sql.casWon(won).won === true);
800
+ var lostStmt = edb.prepare(casQ.sql);
801
+ var lost = lostStmt.run.apply(lostStmt, casQ.params);
802
+ check("guardedUpdate live: loser casWon false (rowCount 0)",
803
+ sql.casWon(lost).won === false && sql.casWon(lost).rowCount === 0);
804
+ check("guardedUpdate live: row advanced exactly once",
805
+ edb.prepare("SELECT status FROM orders WHERE id = 7").get().status === "shipped");
806
+ edb.close();
807
+ }
712
808
  }
713
809
 
714
810
  module.exports = { run: run };
@@ -105,6 +105,23 @@ async function run() {
105
105
  check("OPCODE_TEXT exposed", b.wsClient.OPCODE_TEXT === 0x01);
106
106
  check("CLOSE_NORMAL exposed", b.wsClient.CLOSE_NORMAL === 1000);
107
107
 
108
+ // #368 — WsClientError carries a per-code terminal/transient flag so a
109
+ // consumer's reconnect loop can read err.permanent directly instead of
110
+ // re-deriving the framework's error taxonomy. Terminal: config / 4xx /
111
+ // accept-mismatch / protocol-violation. Transient: 5xx / handshake- &
112
+ // pong-timeout / a dropped socket. A new/unknown code fails CLOSED (terminal).
113
+ var WCE = b.wsClient.WsClientError;
114
+ check("err: bad-url is terminal", new WCE("ws-client/bad-url", "x").permanent === true);
115
+ check("err: accept-mismatch is terminal", new WCE("ws-client/accept-mismatch", "x").permanent === true);
116
+ check("err: protocol-error is terminal", new WCE("ws-client/protocol-error", "x").permanent === true);
117
+ check("err: handshake-timeout is transient", new WCE("ws-client/handshake-timeout", "x").permanent === false);
118
+ check("err: pong-timeout is transient", new WCE("ws-client/pong-timeout", "x").permanent === false);
119
+ check("err: bad-status 403 terminal + statusCode",
120
+ new WCE("ws-client/bad-status", "x", 403).permanent === true && new WCE("ws-client/bad-status", "x", 403).statusCode === 403);
121
+ check("err: bad-status 503 transient + statusCode",
122
+ new WCE("ws-client/bad-status", "x", 503).permanent === false && new WCE("ws-client/bad-status", "x", 503).statusCode === 503);
123
+ check("err: unknown code fails closed (terminal)", new WCE("ws-client/some-future-code", "x").permanent === true);
124
+
108
125
  // ---- bad URL ----
109
126
  rejects("connect: bad URL scheme",
110
127
  function () { b.wsClient.connect("http://example.com"); }, /must start with ws/);
@@ -201,8 +218,28 @@ async function run() {
201
218
  c4.on("error", function (e) { c4Err = e; });
202
219
  await _sleep(300);
203
220
  check("non-101: error emitted", c4Err && c4Err.code === "ws-client/bad-status");
221
+ // #368 — a 4xx handshake rejection is TERMINAL (permanent), carries the status.
222
+ check("non-101 4xx: err.permanent === true", c4Err && c4Err.permanent === true);
223
+ check("non-101 4xx: err.statusCode === 403", c4Err && c4Err.statusCode === 403);
224
+ check("non-101 4xx: err.status alias preserved", c4Err && c4Err.status === 403);
204
225
  server4.close();
205
226
 
227
+ // #368 — a 5xx handshake rejection is TRANSIENT: err.permanent === false so a
228
+ // consumer (and the client's own auto-reconnect) can retry. The single
229
+ // bad-status code is split by the carried status, not re-derived by the caller.
230
+ var server4b = await _makeServer({ rejectStatus: 503 });
231
+ var port4b = server4b.address().port;
232
+ var c4b = b.wsClient.connect("ws://127.0.0.1:" + port4b, {
233
+ reconnect: false, audit: false, allowInternal: true,
234
+ });
235
+ var c4bErr = null;
236
+ c4b.on("error", function (e) { c4bErr = e; });
237
+ await _sleep(300);
238
+ check("non-101 5xx: err.code is bad-status", c4bErr && c4bErr.code === "ws-client/bad-status");
239
+ check("non-101 5xx: err.permanent === false (transient)", c4bErr && c4bErr.permanent === false);
240
+ check("non-101 5xx: err.statusCode === 503", c4bErr && c4bErr.statusCode === 503);
241
+ server4b.close();
242
+
206
243
  // ---- subprotocol negotiation ----
207
244
  var server5 = await _makeServer({ subprotocol: "json-stream-v1" });
208
245
  var port5 = server5.address().port;
@@ -343,6 +380,24 @@ async function run() {
343
380
  check("permanent: error fired once", c11ErrCount === 1);
344
381
  server4xx.close();
345
382
 
383
+ // #368 — a 5xx handshake rejection is TRANSIENT, so the client's own
384
+ // auto-reconnect now fires (it was silently dead while every WsClientError was
385
+ // alwaysPermanent → _isPermanentError always true → willReconnect always false).
386
+ var server5xx = await _makeServer({ rejectStatus: 503 });
387
+ var port5xx = server5xx.address().port;
388
+ var c12 = b.wsClient.connect("ws://127.0.0.1:" + port5xx, {
389
+ reconnect: { maxAttempts: 1, baseMs: 50, maxMs: 100 },
390
+ audit: false,
391
+ allowInternal: true,
392
+ });
393
+ var c12Reconnecting = 0;
394
+ c12.on("error", function () {});
395
+ c12.on("reconnecting", function () { c12Reconnecting += 1; });
396
+ await _sleep(500);
397
+ check("transient: 503 → schedules a reconnect", c12Reconnecting >= 1);
398
+ c12.close();
399
+ server5xx.close();
400
+
346
401
  // ---- close() reason length cap (>123 bytes truncated) ----
347
402
  var serverCl = await _makeServer({});
348
403
  var portCl = serverCl.address().port;
@@ -203,6 +203,35 @@ void os;
203
203
  var FILE_TIMEOUT_MS = parseInt(process.env.SMOKE_FILE_TIMEOUT_MS || "300000", 10);
204
204
  if (!Number.isFinite(FILE_TIMEOUT_MS) || FILE_TIMEOUT_MS < 1000) FILE_TIMEOUT_MS = 300000;
205
205
 
206
+ // Solo files (SMOKE_RUN_SOLO — CPU-bound scans that fan out across the whole
207
+ // box, e.g. the duplicate-block pattern catalog) get a MULTIPLIED budget. They
208
+ // run ALONE, so a generous timeout can't mask a parallel-contention hang, and
209
+ // their cost scales with the lib/ + test/ corpus, which grows every release —
210
+ // a single fixed budget needs bumping each time the corpus crosses it on a
211
+ // low-core runner (macos-latest = 3 cores), the recurring release friction
212
+ // this removes. Multiplying the base budget decouples solo headroom from the
213
+ // pool budget so the treadmill stops. A genuine solo-file hang is still caught.
214
+ var SOLO_TIMEOUT_MULT = parseInt(process.env.SMOKE_SOLO_TIMEOUT_MULT || "4", 10);
215
+ if (!Number.isFinite(SOLO_TIMEOUT_MULT) || SOLO_TIMEOUT_MULT < 1) SOLO_TIMEOUT_MULT = 4;
216
+ var SOLO_TIMEOUT_MS = FILE_TIMEOUT_MS * SOLO_TIMEOUT_MULT;
217
+
218
+ // A forked file that fails at the PROCESS level — a spawn error (EAGAIN /
219
+ // EMFILE under load) or a non-zero exit AFTER its assertions already passed (a
220
+ // leaked-handle teardown, far more common on a resource-starved macos runner)
221
+ // — is retried ONCE. A CLEAN assertion failure (the test ran and reported
222
+ // ok:false) and a watchdog timeout are NOT retried: those are deterministic /
223
+ // already-budgeted, so retrying would only mask a real failure or burn another
224
+ // full budget. The retry recovers the transient-runner case without hiding a
225
+ // real one (a deterministic failure fails again).
226
+ var FORK_RETRIES = parseInt(process.env.SMOKE_FORK_RETRIES || "1", 10);
227
+ if (!Number.isFinite(FORK_RETRIES) || FORK_RETRIES < 0) FORK_RETRIES = 1;
228
+
229
+ // Opt-in leaked-handle audit (SMOKE_AUDIT_HANDLES=1). The worker always
230
+ // computes the per-file leak set (cheap); this gate only controls REPORTING,
231
+ // because the snapshot-diff over-reports async-closing handles as false
232
+ // positives. Turn it on to triage the real-leak population for a cleanup pass.
233
+ var AUDIT_HANDLES = !!process.env.SMOKE_AUDIT_HANDLES;
234
+
206
235
  // _readTimings / _writeTimings — persist per-test durations under
207
236
  // .test-output/smoke-timings.json so the next run's LPT scheduler can
208
237
  // place long-tail tests on the first worker. Median of last 5 runs to
@@ -264,7 +293,8 @@ function _writeTimings(latest) {
264
293
  // The child writes a JSON result line to stdout and exits 0/1. Output
265
294
  // from the test (helpers.check FAIL messages, etc.) goes to the
266
295
  // child's stdout/stderr which we pipe to the parent.
267
- function _runFileForked(modulePath, displayName) {
296
+ function _runFileForked(modulePath, displayName, timeoutMs) {
297
+ var budget = (typeof timeoutMs === "number" && timeoutMs > 0) ? timeoutMs : FILE_TIMEOUT_MS;
268
298
  return new Promise(function (resolve) {
269
299
  var fileStart = Date.now();
270
300
  var workerScript = path.join(__dirname, "_smoke-worker.js");
@@ -287,25 +317,29 @@ function _runFileForked(modulePath, displayName) {
287
317
  }
288
318
  var watchdog = setTimeout(function () {
289
319
  // Child overran the budget with no exit — reap it and report a
290
- // failure that names the file + the most likely cause.
320
+ // failure that names the file + the most likely cause. NOT retriable:
321
+ // a timeout is already-budgeted, so a retry would just burn another
322
+ // full budget.
291
323
  try { child.kill("SIGKILL"); } catch (_e) { /* already gone */ }
292
324
  settle({
293
325
  ok: false,
294
326
  ms: Date.now() - fileStart,
295
327
  checks: 0,
296
- error: "watchdog: '" + displayName + "' exceeded " + FILE_TIMEOUT_MS +
328
+ error: "watchdog: '" + displayName + "' exceeded " + budget +
297
329
  "ms with no exit — likely a leaked handle (timer / socket / fs.watch). " +
298
330
  "Last stderr: " + (stderrBuf.slice(-500) || "(none)"),
299
331
  stderr: stderrBuf,
300
332
  displayName: displayName,
333
+ retriable: false,
301
334
  });
302
- }, FILE_TIMEOUT_MS);
335
+ }, budget);
303
336
  if (typeof watchdog.unref === "function") watchdog.unref();
304
337
  child.stdout.on("data", function (d) { stdoutBuf += d.toString("utf8"); });
305
338
  child.stderr.on("data", function (d) { stderrBuf += d.toString("utf8"); });
306
339
  child.on("error", function (e) {
307
340
  // fork() itself failed (ENOENT / EMFILE / spawn error) — without
308
- // this handler the Promise would never resolve and hang the run.
341
+ // this handler the Promise would never resolve and hang the run. A
342
+ // spawn failure under load (EAGAIN / EMFILE) is transient → retriable.
309
343
  settle({
310
344
  ok: false,
311
345
  ms: Date.now() - fileStart,
@@ -313,6 +347,7 @@ function _runFileForked(modulePath, displayName) {
313
347
  error: displayName + ": fork error: " + ((e && e.message) || String(e)),
314
348
  stderr: stderrBuf,
315
349
  displayName: displayName,
350
+ retriable: true,
316
351
  });
317
352
  });
318
353
  child.on("close", function (code) {
@@ -323,18 +358,50 @@ function _runFileForked(modulePath, displayName) {
323
358
  var parsed;
324
359
  try { parsed = JSON.parse(resultLine); }
325
360
  catch (_e) { parsed = { ok: false, error: "no result line; stderr: " + stderrBuf.slice(0, 500) }; }
361
+ // A non-zero exit AFTER the assertions passed (parsed.ok === true) is a
362
+ // process-level teardown failure — a leaked handle that delays / faults
363
+ // exit, common on a resource-starved runner — and is retriable. A clean
364
+ // assertion failure (parsed.ok === false) is deterministic, NOT retriable.
365
+ // Retriable process-level transients: a non-zero exit after the
366
+ // assertions passed (a leaked handle faulting exit) OR a late async error
367
+ // the worker attributed (parsed.lateError). Both differ from a clean
368
+ // assertion failure (parsed.ok === false WITHOUT lateError), which is
369
+ // deterministic and NOT retried.
370
+ var processFailedAfterPass = code !== 0 && parsed.ok === true;
371
+ var lateError = parsed.lateError === true;
326
372
  settle({
327
373
  ok: code === 0 && parsed.ok,
328
374
  ms: ms,
329
375
  checks: parsed.checks || 0,
330
- error: parsed.error,
376
+ error: parsed.error || (processFailedAfterPass
377
+ ? (displayName + ": process exited non-zero (" + code + ") after assertions passed " +
378
+ "— likely a leaked handle on a slow runner. Last stderr: " + (stderrBuf.slice(-300) || "(none)"))
379
+ : undefined),
331
380
  stderr: stderrBuf,
332
381
  displayName: displayName,
382
+ retriable: processFailedAfterPass || lateError,
383
+ leaks: Array.isArray(parsed.leaks) ? parsed.leaks : [],
333
384
  });
334
385
  });
335
386
  });
336
387
  }
337
388
 
389
+ // Run a forked file, retrying ONCE (FORK_RETRIES) on a PROCESS-level transient
390
+ // (spawn error / non-zero-exit-after-pass) but never on a clean assertion
391
+ // failure or a watchdog timeout — recovers a starved-runner flake without
392
+ // masking a real failure (a deterministic failure fails again on the retry).
393
+ async function _runFileForkedRetrying(modulePath, displayName, timeoutMs) {
394
+ var rv = await _runFileForked(modulePath, displayName, timeoutMs);
395
+ var attempts = 0;
396
+ while (!rv.ok && rv.retriable && attempts < FORK_RETRIES) {
397
+ attempts += 1;
398
+ console.log(" [retry " + attempts + "/" + FORK_RETRIES + "] " + displayName +
399
+ " — process-level transient (" + (rv.error || "fork failed").slice(0, 120) + ")");
400
+ rv = await _runFileForked(modulePath, displayName, timeoutMs);
401
+ }
402
+ return rv;
403
+ }
404
+
338
405
  async function _runLayer(layerNum, legacyPath, layerName) {
339
406
  // Legacy single-layer file (run only when HS_ONLY isn't set).
340
407
  if (ONLY.length === 0 && fs.existsSync(legacyPath)) {
@@ -401,10 +468,11 @@ async function _runLayer(layerNum, legacyPath, layerName) {
401
468
  (solo ? soloFiles : poolFiles).push(files[pf]);
402
469
  }
403
470
 
404
- // Solo phase — heavy files one at a time, each with the full box.
471
+ // Solo phase — heavy files one at a time, each with the full box and the
472
+ // multiplied solo budget (their cost scales with the growing corpus).
405
473
  for (var si = 0; si < soloFiles.length && !firstFailure; si += 1) {
406
474
  var sName = soloFiles[si];
407
- var srv = await _runFileForked(path.join(dir, sName), layerName + " / " + sName);
475
+ var srv = await _runFileForkedRetrying(path.join(dir, sName), layerName + " / " + sName, SOLO_TIMEOUT_MS);
408
476
  resultsByFile[sName] = srv;
409
477
  newTimings[sName] = srv.ms;
410
478
  if (!srv.ok && !firstFailure) firstFailure = srv;
@@ -428,7 +496,7 @@ async function _runLayer(layerNum, legacyPath, layerName) {
428
496
  var myIdx = cursor++;
429
497
  if (myIdx >= ordered.length) return;
430
498
  var fname = ordered[myIdx];
431
- var rv = await _runFileForked(path.join(dir, fname), layerName + " / " + fname);
499
+ var rv = await _runFileForkedRetrying(path.join(dir, fname), layerName + " / " + fname);
432
500
  resultsByFile[fname] = rv;
433
501
  newTimings[fname] = rv.ms;
434
502
  if (!rv.ok && !firstFailure) firstFailure = rv;
@@ -442,6 +510,7 @@ async function _runLayer(layerNum, legacyPath, layerName) {
442
510
 
443
511
  // Print in original sort() order so the per-run output is stable
444
512
  // for diff-based comparison (the solo/LPT order is internal scheduling).
513
+ var leakReport = [];
445
514
  for (var p = 0; p < files.length; p += 1) {
446
515
  var rf = resultsByFile[files[p]];
447
516
  if (!rf) continue; // pool aborted before this file ran
@@ -450,7 +519,21 @@ async function _runLayer(layerNum, legacyPath, layerName) {
450
519
  if (rf.stderr) process.stderr.write(rf.stderr);
451
520
  throw new Error(rf.displayName + ": " + (rf.error || "fork failed"));
452
521
  }
453
- console.log(" " + _padRight(files[p], 40) + " (" + rf.ms + "ms)");
522
+ console.log(" " + _padRight(files[p], 40) + " (" + rf.ms + "ms)" +
523
+ (AUDIT_HANDLES && rf.leaks && rf.leaks.length ? " [leaked: " + rf.leaks.join(", ") + "]" : ""));
524
+ if (rf.leaks && rf.leaks.length) leakReport.push(files[p] + " :: " + rf.leaks.join(", "));
525
+ }
526
+ // Handle-leak population — opt-in diagnostic (SMOKE_AUDIT_HANDLES=1). A
527
+ // test that leaks a timer / socket / server / worker is the same root that
528
+ // flakes a slow runner (delay) or throws after pass (fork-fail). Off by
529
+ // default because the snapshot-diff over-reports async-CLOSING handles (a
530
+ // server whose close() callback fired but whose handle lingers a tick past
531
+ // the grace window) as false positives; turn it on to triage real leaks.
532
+ if (AUDIT_HANDLES && leakReport.length) {
533
+ console.log("");
534
+ console.log(" ⚠ " + leakReport.length + " layer-0 file(s) held a handle past run() " +
535
+ "(SMOKE_AUDIT_HANDLES — triage: real leak vs async-close-in-flight):");
536
+ for (var lr = 0; lr < leakReport.length; lr += 1) console.log(" " + leakReport[lr]);
454
537
  }
455
538
  helpers.addExternalChecks(totalChecks);
456
539
  _writeTimings(newTimings);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.91",
3
+ "version": "0.4.93",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {