Files
stack/packages/bus/tests/proxy-lock.test.mjs

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}`))));
});