Files
ECC/tests/lib/atomic-write.test.js
T

265 lines
12 KiB
JavaScript

'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const vm = require('vm');
const filename = path.resolve(__dirname, '../../scripts/lib/atomic-write.js');
const source = fs.readFileSync(filename, 'utf8');
// Load the actual helper with a local platform/filesystem facade. File creation,
// fsync, replacement and cleanup remain real; fault injection is module-local.
function loadHelper(platform, rename, { clockStep = 25, read = fs.readFileSync } = {}) {
let elapsed = 0;
let waits = 0;
const filesystem = Object.create(fs);
filesystem.renameSync = rename;
filesystem.readFileSync = read;
const module = { exports: {} };
vm.runInNewContext(source, {
module, exports: module.exports,
require: name => name === 'fs' ? filesystem : require(name),
process: { platform, pid: process.pid },
performance: { now: () => elapsed },
Atomics: { wait: () => { elapsed += clockStep; waits++; } },
SharedArrayBuffer, Int32Array,
}, { filename });
return { ...module.exports, waits: () => waits };
}
let passed = 0;
let failed = 0;
function test(name, run) {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-atomic-rename-')));
const destination = path.join(root, 'state.json');
fs.writeFileSync(destination, 'old');
try { run({ root, destination }); passed++; console.log(`PASS ${name}`); }
catch (error) { failed++; console.error(`FAIL ${name}: ${error.stack}`); }
finally { fs.rmSync(root, { recursive: true, force: true }); }
}
const sharingError = code => Object.assign(new Error(`Injected sharing violation: ${code}`), { code });
for (const code of ['EPERM', 'EACCES', 'EBUSY']) {
test(`Windows ${code} retries atomic replacement without disturbing old bytes`, ({ root, destination }) => {
let attempts = 0;
let guards = 0;
const helper = loadHelper('win32', (temporary, target) => {
attempts++;
assert.strictEqual(fs.readFileSync(target, 'utf8'), 'old');
assert.strictEqual(fs.readFileSync(temporary, 'utf8'), 'new');
if (attempts < 3) throw sharingError(code);
fs.renameSync(temporary, target);
});
helper.writeFileAtomic(destination, 'new', { beforeRename: () => { guards++; } });
assert.strictEqual(attempts, 3);
assert.strictEqual(guards, attempts);
assert.strictEqual(helper.waits(), 2);
assert.strictEqual(fs.readFileSync(destination, 'utf8'), 'new');
assert.deepStrictEqual(fs.readdirSync(root), ['state.json']);
});
test(`Windows permanent ${code} preserves destination and removes private staging`, ({ root, destination }) => {
let attempts = 0;
const expected = sharingError(code);
const helper = loadHelper('win32', (_temporary, target) => {
attempts++;
assert.strictEqual(fs.readFileSync(target, 'utf8'), 'old');
throw expected;
});
assert.throws(() => helper.writeFileAtomic(destination, 'new'), error => error === expected);
assert.strictEqual(attempts, 81, 'Hard attempt bound applies even before the deadline');
assert.strictEqual(helper.waits(), 80);
assert.strictEqual(fs.readFileSync(destination, 'utf8'), 'old');
assert.deepStrictEqual(fs.readdirSync(root), ['state.json']);
});
test(`Windows ${code} recovers sustained sharing contention with ownership rechecked`, ({ root, destination }) => {
let attempts = 0;
let parents = 0;
let guards = 0;
const helper = loadHelper('win32', (temporary, target) => {
attempts++;
assert.strictEqual(fs.readFileSync(target, 'utf8'), 'old');
assert.strictEqual(fs.readFileSync(temporary, 'utf8'), 'new');
if (attempts <= 50) throw sharingError(code);
fs.renameSync(temporary, target);
});
helper.writeFileAtomic(destination, 'new', {
validateParent: () => { parents++; },
beforeRename: () => { guards++; },
});
assert.strictEqual(attempts, 51);
assert.strictEqual(guards, attempts);
assert.ok(parents >= attempts);
assert.strictEqual(helper.waits(), 50);
assert.strictEqual(fs.readFileSync(destination, 'utf8'), 'new');
assert.deepStrictEqual(fs.readdirSync(root), ['state.json']);
});
}
test('Windows retries also stop at the elapsed deadline', ({ root, destination }) => {
let attempts = 0;
const expected = sharingError('EPERM');
const helper = loadHelper('win32', () => { attempts++; throw expected; }, { clockStep: 250 });
assert.throws(() => helper.writeFileAtomic(destination, 'new'), error => error === expected);
assert.strictEqual(attempts, 9);
assert.deepStrictEqual(fs.readdirSync(root), ['state.json']);
});
for (const platform of ['linux', 'darwin']) {
for (const code of ['EPERM', 'EACCES', 'EBUSY']) {
test(`${platform} ${code} propagates immediately with original bytes intact`, ({ root, destination }) => {
let attempts = 0;
const expected = sharingError(code);
const helper = loadHelper(platform, () => { attempts++; throw expected; });
assert.throws(() => helper.writeFileAtomic(destination, 'new'), error => error === expected);
assert.strictEqual(attempts, 1);
assert.strictEqual(helper.waits(), 0);
assert.strictEqual(fs.readFileSync(destination, 'utf8'), 'old');
assert.deepStrictEqual(fs.readdirSync(root), ['state.json']);
});
}
}
test('Windows unrelated IO failure propagates immediately', ({ root, destination }) => {
let attempts = 0;
const expected = sharingError('EIO');
const helper = loadHelper('win32', () => { attempts++; throw expected; });
assert.throws(() => helper.writeFileAtomic(destination, 'new'), error => error === expected);
assert.strictEqual(attempts, 1);
assert.strictEqual(helper.waits(), 0);
assert.deepStrictEqual(fs.readdirSync(root), ['state.json']);
});
test('ownership revocation between sharing failures prevents another rename', ({ root, destination }) => {
let attempts = 0;
let guards = 0;
const revoked = new Error('Destination ownership revoked');
const helper = loadHelper('win32', () => { attempts++; throw sharingError('EPERM'); });
assert.throws(() => helper.writeFileAtomic(destination, 'new', {
beforeRename() { if (++guards === 2) throw revoked; },
}), error => error === revoked);
assert.strictEqual(attempts, 1);
assert.strictEqual(guards, 2);
assert.strictEqual(fs.readFileSync(destination, 'utf8'), 'old');
assert.deepStrictEqual(fs.readdirSync(root), ['state.json']);
});
test('late ownership revocation stops sustained contention without publication', ({ root, destination }) => {
let attempts = 0;
let guards = 0;
const revoked = new Error('Destination ownership revoked after sustained contention');
const helper = loadHelper('win32', () => { attempts++; throw sharingError('EPERM'); });
assert.throws(() => helper.writeFileAtomic(destination, 'new', {
beforeRename() { if (++guards === 26) throw revoked; },
}), error => error === revoked);
assert.strictEqual(attempts, 25);
assert.strictEqual(guards, 26);
assert.strictEqual(helper.waits(), 25);
assert.strictEqual(fs.readFileSync(destination, 'utf8'), 'old');
assert.deepStrictEqual(fs.readdirSync(root), ['state.json']);
});
test('parent replacement during retry preserves both destinations and original private staging', ({ root, destination }) => {
const parent = path.join(root, 'parent');
const original = path.join(root, 'original');
fs.mkdirSync(parent);
const target = path.join(parent, 'state.json');
fs.writeFileSync(target, 'old');
const identity = fs.statSync(parent).ino;
const revoked = new Error('Parent identity changed');
let attempts = 0;
const helper = loadHelper('win32', () => {
attempts++;
fs.renameSync(parent, original);
fs.mkdirSync(parent);
fs.writeFileSync(target, 'user replacement');
throw sharingError('EPERM');
});
assert.throws(() => helper.writeFileAtomic(target, 'new', {
validateParent() { if (fs.statSync(parent).ino !== identity) throw revoked; },
}), error => error === revoked);
assert.strictEqual(attempts, 1);
assert.strictEqual(fs.readFileSync(target, 'utf8'), 'user replacement');
assert.strictEqual(fs.readFileSync(path.join(original, 'state.json'), 'utf8'), 'old');
const staging = fs.readdirSync(original).filter(name => name.endsWith('.tmp'));
assert.strictEqual(staging.length, 1, 'Do not clean a pathname through a substituted parent');
assert.strictEqual(fs.readFileSync(path.join(original, staging[0]), 'utf8'), 'new');
assert.deepStrictEqual(fs.readdirSync(parent), ['state.json']);
assert.strictEqual(fs.readFileSync(destination, 'utf8'), 'old');
});
for (const code of ['EPERM', 'EACCES', 'EBUSY']) {
test(`Windows reader recovers ${code} and returns the complete published bytes`, ({ root, destination }) => {
fs.writeFileSync(destination, '{"published":true}');
let attempts = 0;
const helper = loadHelper('win32', fs.renameSync, { read(file, options) {
attempts++;
assert.strictEqual(file, destination);
assert.strictEqual(options, 'utf8');
if (attempts < 3) throw sharingError(code);
return fs.readFileSync(file, options);
} });
assert.deepStrictEqual(JSON.parse(helper.readFileWithSharingRetry(destination, 'utf8')), { published: true });
assert.strictEqual(attempts, 3);
assert.strictEqual(helper.waits(), 2);
assert.deepStrictEqual(fs.readdirSync(root), ['state.json']);
});
test(`Windows reader preserves the final permanent ${code} and never writes`, ({ root, destination }) => {
let attempts = 0;
const expected = sharingError(code);
const helper = loadHelper('win32', fs.renameSync, { read() { attempts++; throw expected; } });
assert.throws(() => helper.readFileWithSharingRetry(destination, 'utf8'), error => error === expected);
assert.strictEqual(attempts, 21);
assert.strictEqual(helper.waits(), 20);
assert.strictEqual(fs.readFileSync(destination, 'utf8'), 'old');
assert.deepStrictEqual(fs.readdirSync(root), ['state.json']);
});
}
for (const platform of ['linux', 'darwin']) {
for (const code of ['EPERM', 'EACCES', 'EBUSY']) {
test(`${platform} reader propagates ${code} immediately`, ({ destination }) => {
let attempts = 0;
const expected = sharingError(code);
const helper = loadHelper(platform, fs.renameSync, { read() { attempts++; throw expected; } });
assert.throws(() => helper.readFileWithSharingRetry(destination, 'utf8'), error => error === expected);
assert.strictEqual(attempts, 1);
assert.strictEqual(helper.waits(), 0);
});
}
}
test('Windows reader stops at the elapsed deadline', ({ destination }) => {
let attempts = 0;
const expected = sharingError('EPERM');
const helper = loadHelper('win32', fs.renameSync, { clockStep: 250, read() { attempts++; throw expected; } });
assert.throws(() => helper.readFileWithSharingRetry(destination, 'utf8'), error => error === expected);
assert.strictEqual(attempts, 4);
});
test('Windows reader does not retry missing files', ({ root }) => {
const helper = loadHelper('win32', fs.renameSync);
assert.throws(() => helper.readFileWithSharingRetry(path.join(root, 'missing'), 'utf8'), error => error.code === 'ENOENT');
assert.strictEqual(helper.waits(), 0);
});
test('Windows reader does not retry unrelated IO or JSON parse failures', ({ destination }) => {
const expected = sharingError('EIO');
const denied = loadHelper('win32', fs.renameSync, { read() { throw expected; } });
assert.throws(() => denied.readFileWithSharingRetry(destination, 'utf8'), error => error === expected);
assert.strictEqual(denied.waits(), 0);
fs.writeFileSync(destination, '{malformed');
let reads = 0;
const invalid = loadHelper('win32', fs.renameSync, { read(file, options) { reads++; return fs.readFileSync(file, options); } });
assert.throws(() => JSON.parse(invalid.readFileWithSharingRetry(destination, 'utf8')), /JSON|property|Unexpected/);
assert.strictEqual(reads, 1);
assert.strictEqual(invalid.waits(), 0);
});
console.log(`Results: Passed: ${passed}, Failed: ${failed}`);
process.exitCode = failed ? 1 : 0;