mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-15 04:35:29 +02:00
152 lines
6.3 KiB
JavaScript
152 lines
6.3 KiB
JavaScript
'use strict';
|
|
const assert = require('assert');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { spawn } = require('child_process');
|
|
const { acquireSettingsLock } = require('../../scripts/lib/install/claude-settings-lock');
|
|
|
|
async function owner(root, settingsPath) {
|
|
const child = spawn(process.execPath, [
|
|
path.join(__dirname, 'helpers', 'settings-lock-worker.js'), root, settingsPath,
|
|
], { env: { ...process.env, HOME: root, USERPROFILE: root, ECC_AGENT_DATA_HOME: path.join(root, '.claude') },
|
|
stdio: ['ignore', 'ignore', 'pipe'] });
|
|
let stderr = '';
|
|
child.stderr.on('data', data => { stderr += data; });
|
|
child.done = new Promise((resolve, reject) => {
|
|
child.on('error', reject);
|
|
child.on('close', (code, signal) => resolve({ code, signal, stderr }));
|
|
});
|
|
child.done.catch(() => {});
|
|
const deadline = Date.now() + 10000;
|
|
try {
|
|
while (!fs.existsSync(path.join(root, 'held'))) {
|
|
if (child.exitCode !== null || Date.now() > deadline) throw new Error('Owner failed: ' + stderr);
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
}
|
|
return child;
|
|
} catch (error) {
|
|
child.kill('SIGKILL');
|
|
await child.done;
|
|
throw error;
|
|
}
|
|
}
|
|
async function withOwner(callback) {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-settings-wait-'));
|
|
const settingsPath = path.join(root, '.claude', 'session-aliases.json');
|
|
let child;
|
|
try {
|
|
child = await owner(root, settingsPath);
|
|
await callback(root, settingsPath, child);
|
|
} finally {
|
|
if (child) {
|
|
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
|
await child.done;
|
|
}
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
const cases = [
|
|
['default contention is fail-fast; optional timeout is bounded and leaves owner intact', () => withOwner((_root, file) => {
|
|
const before = fs.readFileSync(file + '.ecc.lock', 'utf8');
|
|
assert.throws(() => acquireSettingsLock(file), /Another ECC process/);
|
|
const start = performance.now();
|
|
assert.throws(() => acquireSettingsLock(file, { timeoutMs: 50 }), /Another ECC process/);
|
|
assert.ok(performance.now() - start >= 40, 'Waits for the configured deadline');
|
|
assert.ok(performance.now() - start < 5000, 'Does not wait indefinitely');
|
|
assert.strictEqual(fs.readFileSync(file + '.ecc.lock', 'utf8'), before);
|
|
assert.deepStrictEqual(fs.readdirSync(path.dirname(file)), ['session-aliases.json.ecc.lock']);
|
|
})],
|
|
['waiting succeeds when the real owner releases after the first contention', () => withOwner(async (root, file, child) => {
|
|
const originalLink = fs.linkSync;
|
|
let contended = false;
|
|
fs.linkSync = function (source, target) {
|
|
try { return originalLink.apply(this, arguments); }
|
|
catch (error) {
|
|
if (target === file + '.ecc.lock' && error.code === 'EEXIST') {
|
|
contended = true;
|
|
fs.writeFileSync(path.join(root, 'release'), '');
|
|
}
|
|
throw error;
|
|
}
|
|
};
|
|
let release;
|
|
try { release = acquireSettingsLock(file, { timeoutMs: 2000 }); }
|
|
finally { fs.linkSync = originalLink; }
|
|
assert.ok(contended);
|
|
release();
|
|
const result = await child.done;
|
|
assert.strictEqual(result.code, 0, result.stderr);
|
|
assert.ok(!fs.existsSync(file + '.ecc.lock'));
|
|
})],
|
|
['a killed owner is recovered by the next alias transaction', () => withOwner(async (root, file, child) => {
|
|
child.kill('SIGKILL');
|
|
await child.done;
|
|
const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, ECC_AGENT_DATA_HOME: process.env.ECC_AGENT_DATA_HOME };
|
|
Object.assign(process.env, { HOME: root, USERPROFILE: root, ECC_AGENT_DATA_HOME: path.dirname(file) });
|
|
try {
|
|
const aliases = require('../../scripts/lib/session-aliases');
|
|
assert.strictEqual(aliases.setAlias('recovered', '/session').success, true);
|
|
assert.ok(aliases.loadAliases().aliases.recovered);
|
|
assert.deepStrictEqual(fs.readdirSync(path.dirname(file)), ['session-aliases.json']);
|
|
} finally {
|
|
for (const [key, value] of Object.entries(saved)) {
|
|
if (value === undefined) delete process.env[key]; else process.env[key] = value;
|
|
}
|
|
}
|
|
})],
|
|
['non-contention errors propagate without retry', () => withOwner((_root, file) => {
|
|
const originalLink = fs.linkSync;
|
|
const expected = Object.assign(new Error('disk failure'), { code: 'EIO' });
|
|
let attempts = 0;
|
|
fs.linkSync = () => { attempts++; throw expected; };
|
|
try {
|
|
assert.throws(() => acquireSettingsLock(file, { timeoutMs: 2000 }), error => error === expected);
|
|
assert.strictEqual(attempts, 1);
|
|
} finally { fs.linkSync = originalLink; }
|
|
})],
|
|
['recovery tolerates a lock disappearing between inspection and quarantine', () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-lock-disappeared-'));
|
|
const file = path.join(root, 'settings.json');
|
|
const lock = file + '.ecc.lock';
|
|
fs.writeFileSync(lock, '{}');
|
|
fs.utimesSync(lock, new Date(0), new Date(0));
|
|
const originalRename = fs.renameSync;
|
|
let raced = false;
|
|
fs.renameSync = function (source, target) {
|
|
if (source === lock && target.includes('.stale-') && !raced) {
|
|
raced = true;
|
|
fs.unlinkSync(lock);
|
|
}
|
|
return originalRename.apply(this, arguments);
|
|
};
|
|
try {
|
|
// Quarantine IO can consume a short deadline on native Windows runners.
|
|
// Leave room for the next acquisition attempt after the injected ENOENT;
|
|
// the separate contention case verifies the short bounded timeout.
|
|
const release = acquireSettingsLock(file, { timeoutMs: 2000 });
|
|
assert.ok(raced);
|
|
release();
|
|
assert.deepStrictEqual(fs.readdirSync(root), []);
|
|
} finally {
|
|
fs.renameSync = originalRename;
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}],
|
|
['invalid timeout is rejected before touching disk', () => {
|
|
for (const timeoutMs of [-1, NaN, Infinity, '1']) {
|
|
assert.throws(() => acquireSettingsLock('unused', { timeoutMs }), /timeoutMs/);
|
|
}
|
|
}],
|
|
];
|
|
(async () => {
|
|
let passed = 0;
|
|
let failed = 0;
|
|
for (const [name, run] of cases) {
|
|
try { await run(); passed++; console.log('PASS ' + name); }
|
|
catch (error) { failed++; console.error('FAIL ' + name + ': ' + error.stack); }
|
|
}
|
|
console.log('Results: Passed: ' + passed + ', Failed: ' + failed);
|
|
process.exitCode = failed ? 1 : 0;
|
|
})();
|