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

OP OpenVibers committed 1 Oct 2026 · 124c1a0e99 · 3 files, +87 −22


Changes

ddiff --git a/test/helpers/old-release.js b/test/helpers/old-release.js
nnew file mode 100644
iindex 00000000..d9e63921
---- /dev/null
++++ b/test/helpers/old-release.js
@@@ -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
ddiff --git a/test/n-1/harness.js b/test/n-1/harness.js
iindex 1fdd969f..d22ba999 100644
---- a/test/n-1/harness.js
++++ b/test/n-1/harness.js
@@@ -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 }] */
ddiff --git a/test/rollback-newer-writes.test.js b/test/rollback-newer-writes.test.js
iindex e73de4da..524c303c 100644
---- a/test/rollback-newer-writes.test.js
++++ b/test/rollback-newer-writes.test.js
@@@ -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 to
const 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));