101 lines
6.0 KiB
JavaScript
101 lines
6.0 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { spawn } from 'node:child_process';
|
|
import { createProxyLock } from '../src/proxy-lock.mjs';
|
|
|
|
function fixture(t) {
|
|
const dir = fs.mkdtempSync(join(tmpdir(), 's2-lock-'));
|
|
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
return { dir, lock: createProxyLock({ stateDir: dir, identity: 'instance-one' }) };
|
|
}
|
|
const code = name => error => error.code === name;
|
|
test('a fence survives a lock owner and blocks another owner', async t => {
|
|
const { dir, lock } = fixture(t);
|
|
await lock.run(async () => { lock.fence(); });
|
|
const other = createProxyLock({ stateDir: dir, identity: 'instance-one' });
|
|
assert.throws(() => other.assertCertain(), code('proxy-uncertain'));
|
|
await assert.rejects(other.run(async () => {}), code('proxy-uncertain'));
|
|
});
|
|
test('a conflicting proxy identity refuses instead of sharing state', async t => {
|
|
const { dir, lock } = fixture(t);
|
|
await lock.run(async () => {});
|
|
assert.throws(() => createProxyLock({ stateDir: dir, identity: 'instance-two' }), code('proxy-lock-invalid'));
|
|
});
|
|
test('an unreadable owner is not guessed stale', async t => {
|
|
const { dir, lock } = fixture(t);
|
|
fs.writeFileSync(join(dir, 'mosaic-keys.lock'), 'broken', { mode: 0o600 });
|
|
await assert.rejects(lock.run(async () => {}), code('proxy-lock-uncertain'));
|
|
});
|
|
test('independent processes serialize and a dead owner can be recovered', async t => {
|
|
const { dir, lock } = fixture(t);
|
|
const moduleUrl = new URL('../src/proxy-lock.mjs', import.meta.url).href;
|
|
const script = `import {createProxyLock} from ${JSON.stringify(moduleUrl)}; const l=createProxyLock({stateDir:process.argv[1],identity:'instance-one'}); await l.run(async()=>{process.stdout.write('locked\\n'); await new Promise(()=>setInterval(()=>{},1000));});`;
|
|
const child = spawn(process.execPath, ['--input-type=module', '-e', script, dir], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); });
|
|
await new Promise((resolve, reject) => { child.stdout.once('data', resolve); child.once('error', reject); child.once('exit', () => reject(Error('child exited early'))); });
|
|
let entered = false;
|
|
const waiting = lock.run(async () => { entered = true; });
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
assert.equal(entered, false);
|
|
child.kill('SIGKILL');
|
|
await new Promise(resolve => child.once('exit', resolve));
|
|
await waiting;
|
|
assert.equal(entered, true);
|
|
});
|
|
test('a process dying with a fence leaves the next owner refused', async t => {
|
|
const { dir, lock } = fixture(t);
|
|
const moduleUrl = new URL('../src/proxy-lock.mjs', import.meta.url).href;
|
|
const script = `import {createProxyLock} from ${JSON.stringify(moduleUrl)}; const l=createProxyLock({stateDir:process.argv[1],identity:'instance-one'}); await l.run(async()=>{l.fence(); process.stdout.write('fenced\\n'); await new Promise(()=>setInterval(()=>{},1000));});`;
|
|
const child = spawn(process.execPath, ['--input-type=module', '-e', script, dir], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); });
|
|
await new Promise((resolve, reject) => { child.stdout.once('data', resolve); child.once('error', reject); child.once('exit', () => reject(Error('child exited early'))); });
|
|
child.kill('SIGKILL');
|
|
await new Promise(resolve => child.once('exit', resolve));
|
|
await assert.rejects(lock.run(async () => {}), code('proxy-uncertain'));
|
|
});
|
|
|
|
test('confirmed writes advance generation and uncertain writes require restart recovery', async t => {
|
|
const { lock } = fixture(t);
|
|
assert.equal(lock.generation(), 0);
|
|
await lock.run(async () => {
|
|
lock.fence({ org: 'alpha', op: 'register' });
|
|
lock.confirm();
|
|
});
|
|
assert.equal(lock.generation(), 1);
|
|
await lock.run(async () => { lock.fence({ org: 'beta', op: 'issue' }); });
|
|
assert.equal(lock.status().org, 'beta');
|
|
assert.throws(() => lock.assertCertain(), code('proxy-uncertain'));
|
|
await assert.rejects(lock.clear({ restarted: false, read: async () => [] }), code('proxy-restart-required'));
|
|
await lock.clear({ restarted: true, read: async () => ['sentinel'] });
|
|
assert.equal(lock.status(), null);
|
|
assert.equal(lock.generation(), 2);
|
|
});
|
|
|
|
test('a crash while writing the fence remains recoverable after restart', async t => {
|
|
const { dir, lock } = fixture(t);
|
|
fs.writeFileSync(join(dir, 'mosaic-keys.uncertain'), '{', { mode: 0o600 });
|
|
assert.throws(() => lock.assertCertain(), code('proxy-uncertain'));
|
|
assert.deepEqual(lock.status(), { invalid: true });
|
|
await lock.clear({ restarted: true, read: async () => [] });
|
|
assert.equal(lock.status(), null);
|
|
});
|
|
|
|
test('a contender waits while another process publishes its lock owner', async t => {
|
|
const { dir, lock } = fixture(t);
|
|
const moduleUrl = new URL('../src/proxy-lock.mjs', import.meta.url).href;
|
|
const script = `import fs from 'node:fs'; import {createProxyLock} from ${JSON.stringify(moduleUrl)};
|
|
const l=createProxyLock({stateDir:process.argv[1],identity:'instance-one'});
|
|
const write=fs.writeFileSync; fs.writeFileSync=function(fd,data,...rest){
|
|
if(typeof data==='string' && data.includes('"token"')) { process.stdout.write('publishing\\n'); Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,300); }
|
|
return write.call(this,fd,data,...rest);
|
|
}; await l.run(async()=>{});`;
|
|
const child = spawn(process.execPath, ['--input-type=module', '-e', script, dir], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); });
|
|
await new Promise((resolve, reject) => { child.stdout.once('data', resolve); child.once('error', reject); child.once('exit', () => reject(Error('child exited early'))); });
|
|
await lock.run(async () => {});
|
|
await new Promise((resolve, reject) => child.once('exit', code => code === 0 ? resolve() : reject(Error(`child exit ${code}`))));
|
|
});
|