openvibers/openvibe.network Commits
Run Network's tests in parallel on openvibe-shared v2.8.0 (#49)
Changes
@@@ -23,7 +23,7 @@"multer": "^1.4.5-lts.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.7.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.8.0","pg": "^8.23.0","uuid": "^11.1.0","web-push": "^3.6.7"@@@ -1601,9 +1601,9 @@}},"node_modules/openvibe-shared": {-- "version": "2.7.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.7.0",-- "integrity": "sha512-Vi5SjtNO8oX5zk6cH4emIFDTSwHpte3NNUVHchZ4gLVg1m5cZ7cUvaqiDvkcz+aw8qyNBw8WQm3A5Y8OS5rxYQ==",++ "version": "2.8.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.8.0",++ "integrity": "sha512-QUHp0gGl5VdHa3yvGHXhGNJVD04CFkcWuTK44xZlu39KEnN+vijKiG4LN+3H5KDa9SBVgMlglIXXpgRdR0/dDw==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -32,7 +32,7 @@"multer": "^1.4.5-lts.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.7.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.8.0","pg": "^8.23.0","uuid": "^11.1.0","web-push": "^3.6.7"@@@ -9,8 +9,22 @@ import { fileURLToPath } from 'node:url';const require = createRequire(import.meta.url);const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..');++const MIGRATIONS = path.join(root, 'migrations');++const store = process.env.NETWORK_TEST_STORE || 'pglite';const { createTestDb } = require('openvibe-sdk/testing');--const t = await createTestDb({ migrations: path.join(root, 'migrations'), store: process.env.NETWORK_TEST_STORE || 'pglite', service: 'network', max: 4 });++// On the containers createTestDb migrates through its own fixed 15 s client query timeout, and every test process's++// migrate() queues on the SDK's single global advisory lock (openvibe-sdk/src/db/migrate.js LOCK_KEY): with test files++// in parallel, a later process waits past 15 s and dies with "Query read timeout" before its test starts. So for 'pg'++// createTestDb only creates this process's schema and roles, and the migration runs here on the owner connection with++// a timeout that covers the queue. PGlite migrates in-process with no lock, so it keeps createTestDb's own path.++const t = await createTestDb({ migrations: store === 'pg' ? null : MIGRATIONS, store, service: 'network', max: 4 });++if (t.directUrl) {++ const { createDb } = require('openvibe-sdk/db');++ const owner = createDb({ url: t.directUrl, service: 'network-test-owner', max: 1, queryTimeoutMs: 120000 });++ // A failed migration drops this process's schema and roles, as createTestDb does when its own migrate fails.++ try { await owner.migrate({ dir: MIGRATIONS }); } catch (e) { await owner.close(); await t.close(); throw e; }++ await owner.close();++}// Tests insert rows with small explicit ids. A PostgreSQL identity would continue after them, so generated ids// are set to start at 100000 here, clear of every id a test names.for (const r of await t.db.prepare(`SELECT pg_get_serial_sequence(quote_ident(table_name), column_name) AS seq FROM information_schema.columns@@@ -31,7 +45,7 @@ globalThis.__ovNetworkDdl = async (sql) => {globalThis.__ovNetworkMigrate = async (opts) => {if (!t.directUrl) return t.db.migrate(opts);const { createDb } = require('openvibe-sdk/db');-- const owner = createDb({ url: t.directUrl, service: 'network-test-owner', max: 1 });++ const owner = createDb({ url: t.directUrl, service: 'network-test-owner', max: 1, queryTimeoutMs: 120000 });try { return await owner.migrate(opts); } finally { await owner.close(); }};// initDb() adopts the handle above, attaches the handle helpers and seeds the boot data (OAuth clients, site@@@ -25,41 +25,54 @@ const freePort = () => new Promise((resolve) => { const s = net.createServer();(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-net-budget-'));-- const port = await freePort();-- const child = spawn(process.execPath, [path.join(__dirname, '..', 'server', 'index.js')], {-- env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', PGLITE_DIR: path.join(dir, 'pglite'), NODE_ENV: 'test' },-- stdio: ['ignore', 'ignore', 'pipe'],-- });-- let stderr = '';-- child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-2000); });-- const base = `http://127.0.0.1:${port}`;-- try {-- let up = false;-- const startupDeadline = Date.now() + 60000; // a fresh PGlite database takes well over 10 s to migrate and seed-- while (!up && Date.now() < startupDeadline) {-- up = await fetch(`${base}/api/health`).then((r) => r.ok).catch(() => false);-- if (!up) await new Promise((r) => setTimeout(r, 100));++ // Node's fetch pools keep-alive sockets; the server closes an idle one after its default 5 s keepAliveTimeout, and a++ // slow asset walk under load can hit that window and fail with a transient connection error. This test measures++ // sizes, not connection reuse, so retry those.++ const RETRY = new Set(['UND_ERR_SOCKET', 'ECONNRESET', 'EPIPE', 'UND_ERR_CONNECT_TIMEOUT']);++ const retryFetch = async (url, init) => {++ for (let attempt = 0; ; attempt++) {++ try { return await fetch(url, init); }++ catch (e) {++ if (attempt >= 2 || !RETRY.has(e?.cause?.code)) throw e;++ await new Promise((r) => setTimeout(r, 250));++ }}-- assert.ok(up, `the server did not start:\n${stderr}`);-- // Node's fetch pools keep-alive sockets; the server closes an idle one after its default-- // 5 s keepAliveTimeout, and a slow asset walk under load can hit that window and fail with-- // UND_ERR_SOCKET ("other side closed"). This test measures sizes, not connection reuse.-- const retryFetch = async (url, init) => {-- for (let attempt = 0; ; attempt++) {-- try { return await fetch(url, init); }-- catch (e) {-- if (attempt >= 2 || e?.cause?.code !== 'UND_ERR_SOCKET') throw e;-- await new Promise((r) => setTimeout(r, 250));-- }++ };++ let child, exitInfo = null;++ try {++ // The server is a child on an ephemeral port: a fresh PGlite migrates and seeds before it answers, which with++ // test files in parallel is slower than the 60 s once allowed, and the port freePort() picked can be claimed in++ // the gap before the child binds. Boot again on a new port if the child dies before it answers.++ let base, stderr = '';++ for (let attempt = 0; ; attempt++) {++ const port = await freePort();++ exitInfo = null;++ stderr = '';++ child = spawn(process.execPath, [path.join(__dirname, '..', 'server', 'index.js')], {++ env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', PGLITE_DIR: path.join(dir, `pglite-${attempt}`), NODE_ENV: 'test' },++ stdio: ['ignore', 'ignore', 'pipe'],++ });++ child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-2000); });++ child.once('exit', (code, signal) => { exitInfo = { code, signal, stderr }; });++ base = `http://127.0.0.1:${port}`;++ let up = false;++ const startupDeadline = Date.now() + 150000; // a fresh PGlite database takes well over 10 s to migrate and seed++ while (!up && !exitInfo && Date.now() < startupDeadline) {++ up = await fetch(`${base}/api/health`).then((r) => r.ok).catch(() => false);++ if (!up) await new Promise((r) => setTimeout(r, 100));}-- };++ if (up) break;++ child.kill();++ if (exitInfo && attempt < 2) continue; // the port was taken, or another early boot failure: try a fresh one++ assert.ok(up, `the server did not start:\n${(exitInfo && exitInfo.stderr) || stderr}`);++ }const m = await measure({ base, fetch: retryFetch });const over = check(m, BUDGETS);assert.deepStrictEqual(over, [], format(m, over));console.log(format(m));console.log('perf budget: all checks passed');} finally {-- child.kill();++ if (child) child.kill();fs.rmSync(dir, { recursive: true, force: true });}})().catch((err) => { console.error(err); process.exitCode = 1; });@@@ -144,7 +144,10 @@ function rehearse(args, extraEnv = {}) {const p = await pollReady(`http://127.0.0.1:${stalled.address().port}`, { timeoutMs: 3000, requestMs: 1000 });const took = Date.now() - t;assert.strictEqual(p.body, null, 'no answer read');-- assert.ok(took >= 2500 && took < 6000, `returned in ${took} ms (deadline 3000)`);++ // It must wait for the deadline (>= 2500), and must give up near it rather than hang. The upper bound is loose:++ // with other test files in parallel this process can be descheduled past the timer, so wall-clock can stretch++ // well beyond the 3 s deadline even though the poll itself honoured it.++ assert.ok(took >= 2500 && took < 30000, `returned in ${took} ms (deadline 3000)`);} finally { stalled.closeAllConnections(); await new Promise((r) => stalled.close(r)); }}@@@ -15,4 +15,4 @@const { pathToFileURL } = require('url');// Every test process starts with a migrated database of its own (test/helpers/pg-preload.mjs; plan T2, ADR-035).const preload = pathToFileURL(require('path').join(__dirname, 'helpers', 'pg-preload.mjs')).href;--require('openvibe-shared/test-runner').main({ dir: __dirname, timeoutMs: 300000, pad: 40, parallel: process.env.NETWORK_TEST_STORE === 'pg' ? 2 : 1, hide: /^\[DB\] /, nodeArgs: ['--import', preload] });++require('openvibe-shared/test-runner').main({ dir: __dirname, timeoutMs: 300000, pad: 40, parallel: 'auto', hide: /^\[DB\] /, nodeArgs: ['--import', preload] });@@@ -15,6 +15,9 @@ const path = require('path');const ROOT = path.join(__dirname, '..');++/** Transient connection errors worth one more try on an idempotent request (a pooled socket the server closed). */++const NET_RETRY = new Set(['UND_ERR_SOCKET', 'ECONNRESET', 'EPIPE', 'UND_ERR_CONNECT_TIMEOUT']);++/** The path an Express 4 layer is mounted at ('' for app-level middleware), or null when it is a pattern. */function mountPath(layer) {if (!layer.regexp || layer.regexp.fast_slash) return '';@@@ -171,14 +174,25 @@ async function crawl(base, paths, people, needlesFor, { method = 'GET', body, coawait Promise.all(Array.from({ length: concurrency }, async () => {while (queue.length) {const p = queue.shift();++ const h = { 'x-forwarded-for': nextIp(), ...headers };++ let b;++ if (body !== undefined) { if (typeof body === 'string') b = body; else { b = JSON.stringify(body); h['content-type'] = 'application/json'; } }++ // The server closes a pooled connection after its 5 s keep-alive. With test files in parallel a crawl can++ // outrun that between requests, and undici then hands back a socket the server just closed ("fetch failed",++ // status 0), which used to fail `answered` spuriously. Retry that race on a GET (no side effects); a write++ // stays single-shot so a retry can never duplicate it.++ const retriable = method === 'GET';let r;-- try {-- const h = { 'x-forwarded-for': nextIp(), ...headers };-- let b;-- if (body !== undefined) { if (typeof body === 'string') b = body; else { b = JSON.stringify(body); h['content-type'] = 'application/json'; } }-- const res = await fetch(base + p, { method, headers: h, body: b, redirect: 'manual' });-- r = { status: res.status, headers: res.headers, text: await res.text() };-- } catch { r = { status: 0, text: '', headers: {} }; }++ for (let attempt = 0; ; attempt++) {++ try {++ const res = await fetch(base + p, { method, headers: h, body: b, redirect: 'manual' });++ r = { status: res.status, headers: res.headers, text: await res.text() };++ break;++ } catch (e) {++ if (!retriable || attempt >= 2 || !NET_RETRY.has(e?.cause?.code)) { r = { status: 0, text: '', headers: {} }; break; }++ await new Promise((res) => setTimeout(res, 250));++ }++ }if (r.status) answered++;const cls = r.status ? `${String(r.status)[0]}xx` : 'none';statuses[cls] = (statuses[cls] || 0) + 1;
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.7.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.8.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
@@ -1601,9 +1601,9 @@
@@ -1601,9 +1601,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "2.7.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.7.0",
- "integrity": "sha512-Vi5SjtNO8oX5zk6cH4emIFDTSwHpte3NNUVHchZ4gLVg1m5cZ7cUvaqiDvkcz+aw8qyNBw8WQm3A5Y8OS5rxYQ==",
+ "version": "2.8.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.8.0",
+ "integrity": "sha512-QUHp0gGl5VdHa3yvGHXhGNJVD04CFkcWuTK44xZlu39KEnN+vijKiG4LN+3H5KDa9SBVgMlglIXXpgRdR0/dDw==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -32,7 +32,7 @@
@@ -32,7 +32,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.7.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.8.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
@@ -9,8 +9,22 @@ import { fileURLToPath } from 'node:url';
@@ -9,8 +9,22 @@ import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
const require = createRequire(import.meta.url);
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
+const MIGRATIONS = path.join(root, 'migrations');
+const store = process.env.NETWORK_TEST_STORE || 'pglite';
const { createTestDb } = require('openvibe-sdk/testing');
const { createTestDb } = require('openvibe-sdk/testing');
-const t = await createTestDb({ migrations: path.join(root, 'migrations'), store: process.env.NETWORK_TEST_STORE || 'pglite', service: 'network', max: 4 });
+// On the containers createTestDb migrates through its own fixed 15 s client query timeout, and every test process's
+// migrate() queues on the SDK's single global advisory lock (openvibe-sdk/src/db/migrate.js LOCK_KEY): with test files
+// in parallel, a later process waits past 15 s and dies with "Query read timeout" before its test starts. So for 'pg'
+// createTestDb only creates this process's schema and roles, and the migration runs here on the owner connection with
+// a timeout that covers the queue. PGlite migrates in-process with no lock, so it keeps createTestDb's own path.
+const t = await createTestDb({ migrations: store === 'pg' ? null : MIGRATIONS, store, service: 'network', max: 4 });
+if (t.directUrl) {
+ const { createDb } = require('openvibe-sdk/db');
+ const owner = createDb({ url: t.directUrl, service: 'network-test-owner', max: 1, queryTimeoutMs: 120000 });
+ // A failed migration drops this process's schema and roles, as createTestDb does when its own migrate fails.
+ try { await owner.migrate({ dir: MIGRATIONS }); } catch (e) { await owner.close(); await t.close(); throw e; }
+ await owner.close();
+}
// Tests insert rows with small explicit ids. A PostgreSQL identity would continue after them, so generated ids
// Tests insert rows with small explicit ids. A PostgreSQL identity would continue after them, so generated ids
// are set to start at 100000 here, clear of every id a test names.
// are set to start at 100000 here, clear of every id a test names.
for (const r of await t.db.prepare(`SELECT pg_get_serial_sequence(quote_ident(table_name), column_name) AS seq FROM information_schema.columns
for (const r of await t.db.prepare(`SELECT pg_get_serial_sequence(quote_ident(table_name), column_name) AS seq FROM information_schema.columns
@@ -31,7 +45,7 @@ globalThis.__ovNetworkDdl = async (sql) => {
@@ -31,7 +45,7 @@ globalThis.__ovNetworkDdl = async (sql) => {
globalThis.__ovNetworkMigrate = async (opts) => {
globalThis.__ovNetworkMigrate = async (opts) => {
if (!t.directUrl) return t.db.migrate(opts);
if (!t.directUrl) return t.db.migrate(opts);
const { createDb } = require('openvibe-sdk/db');
const { createDb } = require('openvibe-sdk/db');
- const owner = createDb({ url: t.directUrl, service: 'network-test-owner', max: 1 });
+ const owner = createDb({ url: t.directUrl, service: 'network-test-owner', max: 1, queryTimeoutMs: 120000 });
try { return await owner.migrate(opts); } finally { await owner.close(); }
try { return await owner.migrate(opts); } finally { await owner.close(); }
};
};
// initDb() adopts the handle above, attaches the handle helpers and seeds the boot data (OAuth clients, site
// initDb() adopts the handle above, attaches the handle helpers and seeds the boot data (OAuth clients, site
@@ -25,41 +25,54 @@ const freePort = () => new Promise((resolve) => { const s = net.createServer();
@@ -25,41 +25,54 @@ const freePort = () => new Promise((resolve) => { const s = net.createServer();
(async () => {
(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-net-budget-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-net-budget-'));
- const port = await freePort();
- const child = spawn(process.execPath, [path.join(__dirname, '..', 'server', 'index.js')], {
- env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', PGLITE_DIR: path.join(dir, 'pglite'), NODE_ENV: 'test' },
- stdio: ['ignore', 'ignore', 'pipe'],
- });
- let stderr = '';
- child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-2000); });
- const base = `http://127.0.0.1:${port}`;
- try {
- let up = false;
- const startupDeadline = Date.now() + 60000; // a fresh PGlite database takes well over 10 s to migrate and seed
- while (!up && Date.now() < startupDeadline) {
- up = await fetch(`${base}/api/health`).then((r) => r.ok).catch(() => false);
- if (!up) await new Promise((r) => setTimeout(r, 100));
+ // Node's fetch pools keep-alive sockets; the server closes an idle one after its default 5 s keepAliveTimeout, and a
+ // slow asset walk under load can hit that window and fail with a transient connection error. This test measures
+ // sizes, not connection reuse, so retry those.
+ const RETRY = new Set(['UND_ERR_SOCKET', 'ECONNRESET', 'EPIPE', 'UND_ERR_CONNECT_TIMEOUT']);
+ const retryFetch = async (url, init) => {
+ for (let attempt = 0; ; attempt++) {
+ try { return await fetch(url, init); }
+ catch (e) {
+ if (attempt >= 2 || !RETRY.has(e?.cause?.code)) throw e;
+ await new Promise((r) => setTimeout(r, 250));
+ }
}
}
- assert.ok(up, `the server did not start:\n${stderr}`);
- // Node's fetch pools keep-alive sockets; the server closes an idle one after its default
- // 5 s keepAliveTimeout, and a slow asset walk under load can hit that window and fail with
- // UND_ERR_SOCKET ("other side closed"). This test measures sizes, not connection reuse.
- const retryFetch = async (url, init) => {
- for (let attempt = 0; ; attempt++) {
- try { return await fetch(url, init); }
- catch (e) {
- if (attempt >= 2 || e?.cause?.code !== 'UND_ERR_SOCKET') throw e;
- await new Promise((r) => setTimeout(r, 250));
- }
+ };
+ let child, exitInfo = null;
+ try {
+ // The server is a child on an ephemeral port: a fresh PGlite migrates and seeds before it answers, which with
+ // test files in parallel is slower than the 60 s once allowed, and the port freePort() picked can be claimed in
+ // the gap before the child binds. Boot again on a new port if the child dies before it answers.
+ let base, stderr = '';
+ for (let attempt = 0; ; attempt++) {
+ const port = await freePort();
+ exitInfo = null;
+ stderr = '';
+ child = spawn(process.execPath, [path.join(__dirname, '..', 'server', 'index.js')], {
+ env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', PGLITE_DIR: path.join(dir, `pglite-${attempt}`), NODE_ENV: 'test' },
+ stdio: ['ignore', 'ignore', 'pipe'],
+ });
+ child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-2000); });
+ child.once('exit', (code, signal) => { exitInfo = { code, signal, stderr }; });
+ base = `http://127.0.0.1:${port}`;
+ let up = false;
+ const startupDeadline = Date.now() + 150000; // a fresh PGlite database takes well over 10 s to migrate and seed
+ while (!up && !exitInfo && Date.now() < startupDeadline) {
+ up = await fetch(`${base}/api/health`).then((r) => r.ok).catch(() => false);
+ if (!up) await new Promise((r) => setTimeout(r, 100));
}
}
- };
+ if (up) break;
+ child.kill();
+ if (exitInfo && attempt < 2) continue; // the port was taken, or another early boot failure: try a fresh one
+ assert.ok(up, `the server did not start:\n${(exitInfo && exitInfo.stderr) || stderr}`);
+ }
const m = await measure({ base, fetch: retryFetch });
const m = await measure({ base, fetch: retryFetch });
const over = check(m, BUDGETS);
const over = check(m, BUDGETS);
assert.deepStrictEqual(over, [], format(m, over));
assert.deepStrictEqual(over, [], format(m, over));
console.log(format(m));
console.log(format(m));
console.log('perf budget: all checks passed');
console.log('perf budget: all checks passed');
} finally {
} finally {
- child.kill();
+ if (child) child.kill();
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
}
}
})().catch((err) => { console.error(err); process.exitCode = 1; });
})().catch((err) => { console.error(err); process.exitCode = 1; });
@@ -144,7 +144,10 @@ function rehearse(args, extraEnv = {}) {
@@ -144,7 +144,10 @@ function rehearse(args, extraEnv = {}) {
const p = await pollReady(`http://127.0.0.1:${stalled.address().port}`, { timeoutMs: 3000, requestMs: 1000 });
const p = await pollReady(`http://127.0.0.1:${stalled.address().port}`, { timeoutMs: 3000, requestMs: 1000 });
const took = Date.now() - t;
const took = Date.now() - t;
assert.strictEqual(p.body, null, 'no answer read');
assert.strictEqual(p.body, null, 'no answer read');
- assert.ok(took >= 2500 && took < 6000, `returned in ${took} ms (deadline 3000)`);
+ // It must wait for the deadline (>= 2500), and must give up near it rather than hang. The upper bound is loose:
+ // with other test files in parallel this process can be descheduled past the timer, so wall-clock can stretch
+ // well beyond the 3 s deadline even though the poll itself honoured it.
+ assert.ok(took >= 2500 && took < 30000, `returned in ${took} ms (deadline 3000)`);
} finally { stalled.closeAllConnections(); await new Promise((r) => stalled.close(r)); }
} finally { stalled.closeAllConnections(); await new Promise((r) => stalled.close(r)); }
}
}
@@ -15,4 +15,4 @@
@@ -15,4 +15,4 @@
const { pathToFileURL } = require('url');
const { pathToFileURL } = require('url');
// Every test process starts with a migrated database of its own (test/helpers/pg-preload.mjs; plan T2, ADR-035).
// Every test process starts with a migrated database of its own (test/helpers/pg-preload.mjs; plan T2, ADR-035).
const preload = pathToFileURL(require('path').join(__dirname, 'helpers', 'pg-preload.mjs')).href;
const preload = pathToFileURL(require('path').join(__dirname, 'helpers', 'pg-preload.mjs')).href;
-require('openvibe-shared/test-runner').main({ dir: __dirname, timeoutMs: 300000, pad: 40, parallel: process.env.NETWORK_TEST_STORE === 'pg' ? 2 : 1, hide: /^\[DB\] /, nodeArgs: ['--import', preload] });
+require('openvibe-shared/test-runner').main({ dir: __dirname, timeoutMs: 300000, pad: 40, parallel: 'auto', hide: /^\[DB\] /, nodeArgs: ['--import', preload] });
@@ -15,6 +15,9 @@ const path = require('path');
@@ -15,6 +15,9 @@ const path = require('path');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
+/** Transient connection errors worth one more try on an idempotent request (a pooled socket the server closed). */
+const NET_RETRY = new Set(['UND_ERR_SOCKET', 'ECONNRESET', 'EPIPE', 'UND_ERR_CONNECT_TIMEOUT']);
+
/** The path an Express 4 layer is mounted at ('' for app-level middleware), or null when it is a pattern. */
/** The path an Express 4 layer is mounted at ('' for app-level middleware), or null when it is a pattern. */
function mountPath(layer) {
function mountPath(layer) {
if (!layer.regexp || layer.regexp.fast_slash) return '';
if (!layer.regexp || layer.regexp.fast_slash) return '';
@@ -171,14 +174,25 @@ async function crawl(base, paths, people, needlesFor, { method = 'GET', body, co
@@ -171,14 +174,25 @@ async function crawl(base, paths, people, needlesFor, { method = 'GET', body, co
await Promise.all(Array.from({ length: concurrency }, async () => {
await Promise.all(Array.from({ length: concurrency }, async () => {
while (queue.length) {
while (queue.length) {
const p = queue.shift();
const p = queue.shift();
+ const h = { 'x-forwarded-for': nextIp(), ...headers };
+ let b;
+ if (body !== undefined) { if (typeof body === 'string') b = body; else { b = JSON.stringify(body); h['content-type'] = 'application/json'; } }
+ // The server closes a pooled connection after its 5 s keep-alive. With test files in parallel a crawl can
+ // outrun that between requests, and undici then hands back a socket the server just closed ("fetch failed",
+ // status 0), which used to fail `answered` spuriously. Retry that race on a GET (no side effects); a write
+ // stays single-shot so a retry can never duplicate it.
+ const retriable = method === 'GET';
let r;
let r;
- try {
- const h = { 'x-forwarded-for': nextIp(), ...headers };
- let b;
- if (body !== undefined) { if (typeof body === 'string') b = body; else { b = JSON.stringify(body); h['content-type'] = 'application/json'; } }
- const res = await fetch(base + p, { method, headers: h, body: b, redirect: 'manual' });
- r = { status: res.status, headers: res.headers, text: await res.text() };
- } catch { r = { status: 0, text: '', headers: {} }; }
+ for (let attempt = 0; ; attempt++) {
+ try {
+ const res = await fetch(base + p, { method, headers: h, body: b, redirect: 'manual' });
+ r = { status: res.status, headers: res.headers, text: await res.text() };
+ break;
+ } catch (e) {
+ if (!retriable || attempt >= 2 || !NET_RETRY.has(e?.cause?.code)) { r = { status: 0, text: '', headers: {} }; break; }
+ await new Promise((res) => setTimeout(res, 250));
+ }
+ }
if (r.status) answered++;
if (r.status) answered++;
const cls = r.status ? `${String(r.status)[0]}xx` : 'none';
const cls = r.status ? `${String(r.status)[0]}xx` : 'none';
statuses[cls] = (statuses[cls] || 0) + 1;
statuses[cls] = (statuses[cls] || 0) + 1;