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