fix: retain capsule locks with distinct ownership tokens

This commit is contained in:
haelyra
2026-10-12 00:29:02 -04:00
parent 1eaf9034c8
commit 831f9672c4
2 changed files with 99 additions and 9 deletions
+14 -7
View File
@@ -68,13 +68,13 @@ function metadataFailure(meta, entries = []) {
return null;
}
function releaseOwnedLock(lockPath, fd, identity) {
function releaseOwnedLock(lockPath, fd, identity, ownerToken) {
let inspectionDenied;
try {
// Keep the original descriptor open while checking ownership so its inode
// cannot be reused. Preserve a replacement detected before release; this
// check is not atomic against noncooperating filesystem mutation.
if (identity) {
// Keep the original descriptor open through the ownership checks. The
// token also distinguishes files when filesystem identities are ambiguous.
// This is not atomic against noncooperating filesystem mutation.
if (identity && ownerToken) {
let current;
try { current = fs.lstatSync(lockPath); } catch (error) {
if (error.code === 'ENOENT') throw new CapsuleError('capsule.lock_lost', 'append lock disappeared before release');
@@ -82,7 +82,8 @@ function releaseOwnedLock(lockPath, fd, identity) {
inspectionDenied = error;
}
if (!inspectionDenied) {
if (!current.isFile() || current.dev !== identity.dev || current.ino !== identity.ino) {
if (!current.isFile() || current.dev !== identity.dev || current.ino !== identity.ino
|| fs.readFileSync(lockPath, 'utf8') !== ownerToken) {
throw new CapsuleError('capsule.lock_lost', 'append lock ownership changed before release');
}
fs.unlinkSync(lockPath);
@@ -113,11 +114,17 @@ function withAppendLock(dir, operation) {
throw error;
}
let identity;
let ownerToken;
try {
identity = fs.fstatSync(fd);
const token = crypto.randomBytes(16).toString('hex');
fs.writeFileSync(fd, token, 'utf8');
ownerToken = token;
return operation();
} finally {
releaseOwnedLock(lockPath, fd, identity);
// Failed initialization keeps its partial barrier for diagnosis, without
// deleting a pathname whose ownership cannot be established.
releaseOwnedLock(lockPath, fd, identity, ownerToken);
}
}
+85 -2
View File
@@ -391,6 +391,45 @@ test('release preserves a detected replacement lock instead of deleting another
} finally { cleanup(dir); }
});
test('release preserves a replacement lock even when inode identity is indistinguishable', () => {
const dir = tempDir('equal-lock-identity');
const lock = appendLock(dir);
const original = { open: fs.openSync, stat: fs.lstatSync, fstat: fs.fstatSync };
let ownedFd;
const ambiguous = stat => ({ ...stat, dev: 0, ino: 0, isFile: () => stat.isFile() });
try {
capsule.Capsule.create(dir);
fs.openSync = (file, flags, ...args) => {
const fd = original.open(file, flags, ...args);
if (file === lock && flags === 'wx') ownedFd = fd;
return fd;
};
fs.fstatSync = (fd, ...args) => {
const stat = original.fstat(fd, ...args);
return fd === ownedFd ? ambiguous(stat) : stat;
};
fs.lstatSync = (file, ...args) => {
const stat = original.stat(file, ...args);
return file === lock ? ambiguous(stat) : stat;
};
const c = capsule.Capsule.open(dir, { clock: () => {
fs.renameSync(lock, path.join(dir, 'displaced-lock'));
fs.writeFileSync(lock, 'replacement owner');
return fixedClock();
} });
assert.throws(() => c.append('plan', 'owner', {}), error => error.code === 'capsule.lock_lost');
assert.strictEqual(fs.readFileSync(lock, 'utf8'), 'replacement owner');
assert.strictEqual(capsule.verify(dir).entry_count, 1);
assert.ok(capsule.verify(dir).ok);
assert.throws(() => capsule.Capsule.open(dir).append('plan', 'blocked', {}), error => error.code === 'capsule.busy');
} finally {
fs.openSync = original.open;
fs.lstatSync = original.stat;
fs.fstatSync = original.fstat;
cleanup(dir);
}
});
test('a lock removed externally is reported as lost after closing owned descriptors', () => {
const dir = tempDir('missing-lock');
try {
@@ -418,10 +457,11 @@ for (const scenario of [
test(`lock release: ${scenario.name}`, () => {
const dir = tempDir('lock-close-boundary');
const lock = appendLock(dir);
const original = { open: fs.openSync, close: fs.closeSync, stat: fs.lstatSync, unlink: fs.unlinkSync };
const original = { open: fs.openSync, close: fs.closeSync, stat: fs.lstatSync, unlink: fs.unlinkSync, write: fs.writeFileSync };
const permissionError = Object.assign(new Error('synthetic lock inspection denied'), { code: scenario.code || 'EPERM' });
const closeError = Object.assign(new Error('synthetic ambiguous close failure'), { code: 'EIO' });
let ownedFd;
let ownedToken;
let closed = false;
let closes = 0;
let inspections = 0;
@@ -433,6 +473,11 @@ for (const scenario of [
if (file === lock && args[0] === 'wx') ownedFd = fd;
return fd;
};
fs.writeFileSync = function(file, data, ...args) {
const result = original.write.call(this, file, data, ...args);
if (file === ownedFd) ownedToken = data;
return result;
};
fs.lstatSync = function(file, ...args) {
if (file === lock) {
inspections += 1;
@@ -471,8 +516,9 @@ for (const scenario of [
fs.closeSync = original.close;
fs.lstatSync = original.stat;
fs.unlinkSync = original.unlink;
fs.writeFileSync = original.write;
if (scenario.outcome === 'missing') assert.strictEqual(fs.existsSync(lock), false);
else assert.strictEqual(fs.readFileSync(lock, 'utf8'), scenario.outcome === 'replacement' ? 'replacement owner' : '');
else assert.strictEqual(fs.readFileSync(lock, 'utf8'), scenario.outcome === 'replacement' ? 'replacement owner' : ownedToken);
// Release failure can follow a complete durable append; never infer rollback.
assert.strictEqual(capsule.verify(dir).entry_count, 1);
assert.strictEqual(capsule.verify(dir).ok, true);
@@ -481,11 +527,48 @@ for (const scenario of [
fs.closeSync = original.close;
fs.lstatSync = original.stat;
fs.unlinkSync = original.unlink;
fs.writeFileSync = original.write;
cleanup(dir);
}
});
}
test('failed ownership-token initialization preserves the primary error and partial barrier', () => {
const dir = tempDir('lock-token-init');
const lock = appendLock(dir);
const original = { open: fs.openSync, write: fs.writeFileSync, close: fs.closeSync };
const failure = Object.assign(new Error('synthetic token initialization failure'), { code: 'EIO' });
let ownedFd;
let closed = false;
try {
const c = capsule.Capsule.create(dir);
fs.openSync = (file, flags, ...args) => {
const fd = original.open(file, flags, ...args);
if (file === lock && flags === 'wx') ownedFd = fd;
return fd;
};
fs.writeFileSync = (file, ...args) => {
if (file === ownedFd) throw failure;
return original.write(file, ...args);
};
fs.closeSync = fd => {
const result = original.close(fd);
if (fd === ownedFd) closed = true;
return result;
};
assert.throws(() => c.append('plan', 'owner', {}), error => error === failure);
assert.strictEqual(closed, true);
assert.strictEqual(fs.readFileSync(lock, 'utf8'), '');
assert.strictEqual(capsule.verify(dir).entry_count, 0);
assert.throws(() => capsule.Capsule.open(dir).append('plan', 'blocked', {}), error => error.code === 'capsule.busy');
} finally {
fs.openSync = original.open;
fs.writeFileSync = original.write;
fs.closeSync = original.close;
cleanup(dir);
}
});
test('invalid payloads leave journal unchanged and release the append lock', () => {
const dir = tempDir();
try {