@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.
- package/CHANGELOG.md +4 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/security-middleware.js +21 -3
- package/lib/vendor/MANIFEST.json +49 -41
- package/lib/vendor/blamejs/CHANGELOG.md +6 -0
- package/lib/vendor/blamejs/SECURITY.md +1 -0
- package/lib/vendor/blamejs/api-snapshot.json +208 -2
- package/lib/vendor/blamejs/examples/wiki/test/e2e.js +7 -4
- package/lib/vendor/blamejs/examples/wiki/test/integration.js +15 -12
- package/lib/vendor/blamejs/index.js +2 -0
- package/lib/vendor/blamejs/lib/audit-sign.js +34 -1
- package/lib/vendor/blamejs/lib/backup/manifest.js +191 -44
- package/lib/vendor/blamejs/lib/codepoint-class.js +284 -77
- package/lib/vendor/blamejs/lib/framework-error.js +14 -0
- package/lib/vendor/blamejs/lib/fsm.js +80 -24
- package/lib/vendor/blamejs/lib/log.js +32 -0
- package/lib/vendor/blamejs/lib/middleware/rate-limit.js +18 -2
- package/lib/vendor/blamejs/lib/middleware/request-id.js +24 -4
- package/lib/vendor/blamejs/lib/request-helpers.js +50 -0
- package/lib/vendor/blamejs/lib/safe-path.js +24 -10
- package/lib/vendor/blamejs/lib/sql.js +133 -0
- package/lib/vendor/blamejs/lib/totp.js +98 -33
- package/lib/vendor/blamejs/lib/ws-client.js +39 -28
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.15.21.json +51 -0
- package/lib/vendor/blamejs/release-notes/v0.15.22.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.15.23.json +22 -0
- package/lib/vendor/blamejs/test/00-primitives.js +80 -0
- package/lib/vendor/blamejs/test/_smoke-worker.js +81 -0
- package/lib/vendor/blamejs/test/integration/federation-auth.test.js +7 -4
- package/lib/vendor/blamejs/test/integration/mail-crypto-smime.test.js +7 -4
- package/lib/vendor/blamejs/test/layer-0-primitives/backup-manifest-signature.test.js +91 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +65 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codepoint-class.test.js +58 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/defineguard-default-gate-posture-caps.test.js +5 -2
- package/lib/vendor/blamejs/test/layer-0-primitives/dpop-middleware-replaystore-required.test.js +9 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/fsm.test.js +99 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/money.test.js +30 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/otlp-attr-redaction.test.js +9 -6
- package/lib/vendor/blamejs/test/layer-0-primitives/rate-limit-xff-spoofing.test.js +36 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/request-helpers.test.js +33 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/request-id-async-context.test.js +117 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/safe-path.test.js +64 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/sql.test.js +96 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ws-client.test.js +55 -0
- package/lib/vendor/blamejs/test/smoke.js +93 -10
- 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 " +
|
|
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
|
-
},
|
|
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
|
|
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
|
|
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