openvibers/openvibe.live Commits
Tests: old-release checkouts get a process-unique worktree name, so parallel test files no longer contend on one .git/worktrees/<name>/index.lock
Changes
nnew file mode 100644@@@ -0,0 +1,75 @@++'use strict';++/**++ * An older release checked out next to this one, for the tests that run old code or old SQL against++ * this release (test/rollback-newer-writes.test.js, test/n-1/harness.js, and the recorder++ * scripts/n-1-record.js uses). The checkout shares this checkout's node_modules.++ *++ * Two things bite when test/run.js runs the files that need one at the same time, and both are++ * handled here rather than in every caller:++ *++ * - Git names the metadata of a linked worktree after the basename of its directory, in the one++ * shared `.git/worktrees/` (a linked worktree's `git-dir` file points at the main repository's++ * common directory). Two checkouts both called `old` therefore share `.git/worktrees/old`, and++ * git serialises them through that one index: whichever arrives second finds++ * `Unable to create '…/.git/worktrees/old/index.lock': File exists` and exits 128 — or worse,++ * deletes the first one's directory and succeeds, so a test then runs a half-written checkout.++ * Every checkout here gets a basename unique to this process (`old-<pid>-<n>`), so concurrent++ * callers never contend on the same metadata.++ * - Even with distinct basenames git keeps other locks in the common directory, so a stray++ * `git add`/`gc` from elsewhere can still hold one briefly. `run()` retries on exactly the++ * lock-contention failures and gives up with git's own message, which `stdio: 'ignore'` used to++ * swallow (the caller only ever saw a bare exit 128).++ *++ * const old = oldRelease(ROOT, 'HEAD');++ * try { runIn(old.dir, …); } finally { old.remove(); }++ */++const fs = require('fs');++const os = require('os');++const path = require('path');++const { execFileSync } = require('child_process');++++let counter = 0;++/** git exiting 128 because another process holds the repository's worktree/index metadata. */++const CONTENDED = /index\.lock|File exists|Unable to create|already exists|cannot create directory|unable to create file/;++++/** Blocking sleep, so the retry does not need a promise: this runs before any async work starts. */++function pause(ms) {++ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);++}++++/** git in `root`, with stderr kept for diagnostics and a retry while the repository is locked. */++function git(root, args, { attempts = 40, waitMs = 250 } = {}) {++ for (let i = 1; ; i++) {++ try {++ return execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });++ } catch (e) {++ const stderr = String(e.stderr || '');++ if (i >= attempts || !CONTENDED.test(stderr)) {++ throw new Error(`git ${args.join(' ')} in ${root} failed (exit ${e.status}) after ${i} attempt${i > 1 ? 's' : ''}: ${stderr.trim() || '(no message)'}`);++ }++ pause(waitMs);++ }++ }++}++++/**++ * A checkout of `ref` (default: HEAD) in its own temp directory, sharing root's node_modules.++ * → { dir, sha, remove() }. `remove()` never throws: a leftover temp directory is worse than a++ * stale metadata entry, which `git worktree prune` clears on any later run.++ */++function oldRelease(root, ref = 'HEAD') {++ const sha = git(root, ['rev-parse', '--verify', `${ref}^{commit}`]).trim();++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-old-release-'));++ const dir = path.join(tmp, `release-${process.pid}-${counter++}`);++ git(root, ['worktree', 'add', '--detach', dir, sha]);++ fs.symlinkSync(path.join(root, 'node_modules'), path.join(dir, 'node_modules'), 'dir');++ return {++ dir, sha,++ remove() {++ try { git(root, ['worktree', 'remove', '--force', dir], { attempts: 5 }); } catch { /* best effort */ }++ fs.rmSync(tmp, { recursive: true, force: true });++ },++ };++}++++module.exports = { oldRelease };\\ No newline at end of file@@@ -22,9 +22,9 @@* server code that prepares on its own schema. The N-1 schema is its sqlite_master after the replay.*/const fs = require('fs');--const os = require('os');const path = require('path');const { execFileSync } = require('child_process');++const { oldRelease } = require('../helpers/old-release');// ── Client calls: static extraction ──────────────────────────@@@ -814,20 +814,9 @@ function insertProblems(db, statements) {// ── Git ──────────────────────────────────────────────────────--/** A detached worktree of `ref` in a temp directory sharing root's node_modules → { dir, sha, remove() }. */++/** A detached checkout of `ref` sharing root's node_modules → { dir, sha, remove() }. test/helpers/old-release.js. */function worktree(root, ref) {-- const sha = execFileSync('git', ['-C', root, 'rev-parse', '--verify', `${ref}^{commit}`], { encoding: 'utf8' }).trim();-- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-n-1-'));-- const dir = path.join(tmp, 'release');-- execFileSync('git', ['-C', root, 'worktree', 'add', '--detach', dir, sha], { stdio: 'ignore' });-- fs.symlinkSync(path.join(root, 'node_modules'), path.join(dir, 'node_modules'), 'dir');-- return {-- dir, sha,-- remove() {-- try { execFileSync('git', ['-C', root, 'worktree', 'remove', '--force', dir], { stdio: 'ignore' }); } catch { /* */ }-- fs.rmSync(tmp, { recursive: true, force: true });-- },-- };++ return oldRelease(root, ref);}/** Files of another repository at a commit (git show), e.g. Live's chat widget for Chat. → [{ name, text }] */@@@ -6,15 +6,17 @@* release of seven days ago: that release's initDb runs over the newer schema without an error, reads* what it knows (users, streams, follows, settings) and writes (a user, a stream, a follow).*-- * The older release is checked out into a temporary git worktree (it shares this checkout's-- * node_modules). Each release runs in its own process. Without git history (a shallow CI clone) the-- * test says so and passes; ROLLBACK_REF=<commit> picks another release.++ * The older release is checked out next to this one (it shares this checkout's node_modules) by++ * test/helpers/old-release.js, which also keeps a concurrently running test file off the same git++ * worktree metadata. Each release runs in its own process. Without git history (a shallow CI clone)++ * the test says so and passes; ROLLBACK_REF=<commit> picks another release.*/const assert = require('assert');const fs = require('fs');const os = require('os');const path = require('path');const { execFileSync, spawnSync } = require('child_process');++const { oldRelease } = require('./helpers/old-release');const ROOT = path.join(__dirname, '..');const git = (...a) => { try { return execFileSync('git', ['-C', ROOT, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };@@@ -23,9 +25,9 @@ if (!old) { console.log('rollback with newer writes: skipped (no git history toconst tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-rollback-'));const dbPath = path.join(tmp, 'live.db');--const oldDir = path.join(tmp, 'old');++let release = null;const cleanup = () => {-- try { execFileSync('git', ['-C', ROOT, 'worktree', 'remove', '--force', oldDir], { stdio: 'ignore' }); } catch { /* not created */ }++ if (release) release.remove();fs.rmSync(tmp, { recursive: true, force: true });};@@@ -59,9 +61,8 @@ try {assert.strictEqual(now.code, 0, `the current release could not prepare the database:\n${now.stderr}`);// 2. The release of a week ago, on that database.-- execFileSync('git', ['-C', ROOT, 'worktree', 'add', '--detach', oldDir, old], { stdio: 'ignore' });-- fs.symlinkSync(path.join(ROOT, 'node_modules'), path.join(oldDir, 'node_modules'), 'dir');-- const back = runIn(oldDir, `++ release = oldRelease(ROOT, old);++ const back = runIn(release.dir, `console.log = () => {}; console.warn = () => {};const errors = [];const origError = console.error; console.error = (...a) => errors.push(a.join(' ').slice(0, 300));
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+'use strict';
+/**
+ * An older release checked out next to this one, for the tests that run old code or old SQL against
+ * this release (test/rollback-newer-writes.test.js, test/n-1/harness.js, and the recorder
+ * scripts/n-1-record.js uses). The checkout shares this checkout's node_modules.
+ *
+ * Two things bite when test/run.js runs the files that need one at the same time, and both are
+ * handled here rather than in every caller:
+ *
+ * - Git names the metadata of a linked worktree after the basename of its directory, in the one
+ * shared `.git/worktrees/` (a linked worktree's `git-dir` file points at the main repository's
+ * common directory). Two checkouts both called `old` therefore share `.git/worktrees/old`, and
+ * git serialises them through that one index: whichever arrives second finds
+ * `Unable to create '…/.git/worktrees/old/index.lock': File exists` and exits 128 — or worse,
+ * deletes the first one's directory and succeeds, so a test then runs a half-written checkout.
+ * Every checkout here gets a basename unique to this process (`old-<pid>-<n>`), so concurrent
+ * callers never contend on the same metadata.
+ * - Even with distinct basenames git keeps other locks in the common directory, so a stray
+ * `git add`/`gc` from elsewhere can still hold one briefly. `run()` retries on exactly the
+ * lock-contention failures and gives up with git's own message, which `stdio: 'ignore'` used to
+ * swallow (the caller only ever saw a bare exit 128).
+ *
+ * const old = oldRelease(ROOT, 'HEAD');
+ * try { runIn(old.dir, …); } finally { old.remove(); }
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { execFileSync } = require('child_process');
+
+let counter = 0;
+/** git exiting 128 because another process holds the repository's worktree/index metadata. */
+const CONTENDED = /index\.lock|File exists|Unable to create|already exists|cannot create directory|unable to create file/;
+
+/** Blocking sleep, so the retry does not need a promise: this runs before any async work starts. */
+function pause(ms) {
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
+}
+
+/** git in `root`, with stderr kept for diagnostics and a retry while the repository is locked. */
+function git(root, args, { attempts = 40, waitMs = 250 } = {}) {
+ for (let i = 1; ; i++) {
+ try {
+ return execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
+ } catch (e) {
+ const stderr = String(e.stderr || '');
+ if (i >= attempts || !CONTENDED.test(stderr)) {
+ throw new Error(`git ${args.join(' ')} in ${root} failed (exit ${e.status}) after ${i} attempt${i > 1 ? 's' : ''}: ${stderr.trim() || '(no message)'}`);
+ }
+ pause(waitMs);
+ }
+ }
+}
+
+/**
+ * A checkout of `ref` (default: HEAD) in its own temp directory, sharing root's node_modules.
+ * → { dir, sha, remove() }. `remove()` never throws: a leftover temp directory is worse than a
+ * stale metadata entry, which `git worktree prune` clears on any later run.
+ */
+function oldRelease(root, ref = 'HEAD') {
+ const sha = git(root, ['rev-parse', '--verify', `${ref}^{commit}`]).trim();
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-old-release-'));
+ const dir = path.join(tmp, `release-${process.pid}-${counter++}`);
+ git(root, ['worktree', 'add', '--detach', dir, sha]);
+ fs.symlinkSync(path.join(root, 'node_modules'), path.join(dir, 'node_modules'), 'dir');
+ return {
+ dir, sha,
+ remove() {
+ try { git(root, ['worktree', 'remove', '--force', dir], { attempts: 5 }); } catch { /* best effort */ }
+ fs.rmSync(tmp, { recursive: true, force: true });
+ },
+ };
+}
+
+module.exports = { oldRelease };
\ No newline at end of file
\ No newline at end of file
@@ -22,9 +22,9 @@
@@ -22,9 +22,9 @@
* server code that prepares on its own schema. The N-1 schema is its sqlite_master after the replay.
* server code that prepares on its own schema. The N-1 schema is its sqlite_master after the replay.
*/
*/
const fs = require('fs');
const fs = require('fs');
-const os = require('os');
const path = require('path');
const path = require('path');
const { execFileSync } = require('child_process');
const { execFileSync } = require('child_process');
+const { oldRelease } = require('../helpers/old-release');
// ── Client calls: static extraction ──────────────────────────
// ── Client calls: static extraction ──────────────────────────
@@ -814,20 +814,9 @@ function insertProblems(db, statements) {
@@ -814,20 +814,9 @@ function insertProblems(db, statements) {
// ── Git ──────────────────────────────────────────────────────
// ── Git ──────────────────────────────────────────────────────
-/** A detached worktree of `ref` in a temp directory sharing root's node_modules → { dir, sha, remove() }. */
+/** A detached checkout of `ref` sharing root's node_modules → { dir, sha, remove() }. test/helpers/old-release.js. */
function worktree(root, ref) {
function worktree(root, ref) {
- const sha = execFileSync('git', ['-C', root, 'rev-parse', '--verify', `${ref}^{commit}`], { encoding: 'utf8' }).trim();
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-n-1-'));
- const dir = path.join(tmp, 'release');
- execFileSync('git', ['-C', root, 'worktree', 'add', '--detach', dir, sha], { stdio: 'ignore' });
- fs.symlinkSync(path.join(root, 'node_modules'), path.join(dir, 'node_modules'), 'dir');
- return {
- dir, sha,
- remove() {
- try { execFileSync('git', ['-C', root, 'worktree', 'remove', '--force', dir], { stdio: 'ignore' }); } catch { /* */ }
- fs.rmSync(tmp, { recursive: true, force: true });
- },
- };
+ return oldRelease(root, ref);
}
}
/** Files of another repository at a commit (git show), e.g. Live's chat widget for Chat. → [{ name, text }] */
/** Files of another repository at a commit (git show), e.g. Live's chat widget for Chat. → [{ name, text }] */
@@ -6,15 +6,17 @@
@@ -6,15 +6,17 @@
* release of seven days ago: that release's initDb runs over the newer schema without an error, reads
* release of seven days ago: that release's initDb runs over the newer schema without an error, reads
* what it knows (users, streams, follows, settings) and writes (a user, a stream, a follow).
* what it knows (users, streams, follows, settings) and writes (a user, a stream, a follow).
*
*
- * The older release is checked out into a temporary git worktree (it shares this checkout's
- * node_modules). Each release runs in its own process. Without git history (a shallow CI clone) the
- * test says so and passes; ROLLBACK_REF=<commit> picks another release.
+ * The older release is checked out next to this one (it shares this checkout's node_modules) by
+ * test/helpers/old-release.js, which also keeps a concurrently running test file off the same git
+ * worktree metadata. Each release runs in its own process. Without git history (a shallow CI clone)
+ * the test says so and passes; ROLLBACK_REF=<commit> picks another release.
*/
*/
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
const { execFileSync, spawnSync } = require('child_process');
const { execFileSync, spawnSync } = require('child_process');
+const { oldRelease } = require('./helpers/old-release');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const git = (...a) => { try { return execFileSync('git', ['-C', ROOT, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
const git = (...a) => { try { return execFileSync('git', ['-C', ROOT, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
@@ -23,9 +25,9 @@ if (!old) { console.log('rollback with newer writes: skipped (no git history to
@@ -23,9 +25,9 @@ if (!old) { console.log('rollback with newer writes: skipped (no git history to
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-rollback-'));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-rollback-'));
const dbPath = path.join(tmp, 'live.db');
const dbPath = path.join(tmp, 'live.db');
-const oldDir = path.join(tmp, 'old');
+let release = null;
const cleanup = () => {
const cleanup = () => {
- try { execFileSync('git', ['-C', ROOT, 'worktree', 'remove', '--force', oldDir], { stdio: 'ignore' }); } catch { /* not created */ }
+ if (release) release.remove();
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
};
};
@@ -59,9 +61,8 @@ try {
@@ -59,9 +61,8 @@ try {
assert.strictEqual(now.code, 0, `the current release could not prepare the database:\n${now.stderr}`);
assert.strictEqual(now.code, 0, `the current release could not prepare the database:\n${now.stderr}`);
// 2. The release of a week ago, on that database.
// 2. The release of a week ago, on that database.
- execFileSync('git', ['-C', ROOT, 'worktree', 'add', '--detach', oldDir, old], { stdio: 'ignore' });
- fs.symlinkSync(path.join(ROOT, 'node_modules'), path.join(oldDir, 'node_modules'), 'dir');
- const back = runIn(oldDir, `
+ release = oldRelease(ROOT, old);
+ const back = runIn(release.dir, `
console.log = () => {}; console.warn = () => {};
console.log = () => {}; console.warn = () => {};
const errors = [];
const errors = [];
const origError = console.error; console.error = (...a) => errors.push(a.join(' ').slice(0, 300));
const origError = console.error; console.error = (...a) => errors.push(a.join(' ').slice(0, 300));