diff --git a/scripts/lib/eval-harness/capsule.js b/scripts/lib/eval-harness/capsule.js index eeb03b1c3..68289a5b9 100644 --- a/scripts/lib/eval-harness/capsule.js +++ b/scripts/lib/eval-harness/capsule.js @@ -103,6 +103,15 @@ function releaseOwnedLock(lockPath, fd, identity, ownerToken) { } } +function writeAll(fd, bytes, target) { + let offset = 0; + while (offset < bytes.length) { + const written = fs.writeSync(fd, bytes, offset, bytes.length - offset, null); + if (written <= 0) throw new CapsuleError('capsule.write_failed', `${target} write made no progress`); + offset += written; + } +} + /** Exclusive cooperative append lock. Never waits or infers stale ownership. */ function withAppendLock(dir, operation) { const lockPath = path.join(dir, APPEND_LOCK_FILE); @@ -118,7 +127,7 @@ function withAppendLock(dir, operation) { try { identity = fs.fstatSync(fd); const token = crypto.randomBytes(16).toString('hex'); - fs.writeFileSync(fd, token, 'utf8'); + writeAll(fd, Buffer.from(token, 'utf8'), 'append lock'); ownerToken = token; return operation(); } finally { @@ -220,12 +229,7 @@ class Capsule { const bytes = Buffer.from(canonicalJson(entry) + '\n', 'utf8'); const fd = fs.openSync(this.journalPath, 'a'); try { - let offset = 0; - while (offset < bytes.length) { - const written = fs.writeSync(fd, bytes, offset, bytes.length - offset, null); - if (written <= 0) throw new CapsuleError('capsule.write_failed', 'journal write made no progress'); - offset += written; - } + writeAll(fd, bytes, 'journal'); fs.fsyncSync(fd); } finally { fs.closeSync(fd); diff --git a/tests/lib/eval-harness/capsule.test.js b/tests/lib/eval-harness/capsule.test.js index 27d4d69dd..219939fd1 100644 --- a/tests/lib/eval-harness/capsule.test.js +++ b/tests/lib/eval-harness/capsule.test.js @@ -304,11 +304,18 @@ test('validation or clock exceptions release ownership so a later append can pro test('short writes complete the entire UTF-8 journal entry before acknowledgement', () => { const dir = tempDir('short-write'); const originalWrite = fs.writeSync; + const originalOpen = fs.openSync; + let journalFd; let chunks = 0; try { const c = capsule.Capsule.create(dir); + fs.openSync = (file, flags, ...args) => { + const fd = originalOpen(file, flags, ...args); + if (file === path.join(dir, capsule.JOURNAL_FILE) && flags === 'a') journalFd = fd; + return fd; + }; fs.writeSync = (fd, data, offset, length, position) => { - if (!Buffer.isBuffer(data)) return originalWrite(fd, data, offset, length); + if (fd !== journalFd) return originalWrite(fd, data, offset, length, position); chunks += 1; return originalWrite(fd, data, offset, Math.min(length, 7), position); }; @@ -317,17 +324,24 @@ test('short writes complete the entire UTF-8 journal entry before acknowledgemen assert.ok(capsule.verify(dir).ok); assert.strictEqual(c.entries()[0].payload.message, 'snow \u2603'); assert.ok(!fs.existsSync(appendLock(dir))); - } finally { fs.writeSync = originalWrite; cleanup(dir); } + } finally { fs.writeSync = originalWrite; fs.openSync = originalOpen; cleanup(dir); } }); test('partial write failure leaves evidence and prevents later append from hiding the tail', () => { const dir = tempDir('partial-write'); const originalWrite = fs.writeSync; + const originalOpen = fs.openSync; + let journalFd; let chunks = 0; try { const c = capsule.Capsule.create(dir); + fs.openSync = (file, flags, ...args) => { + const fd = originalOpen(file, flags, ...args); + if (file === path.join(dir, capsule.JOURNAL_FILE) && flags === 'a') journalFd = fd; + return fd; + }; fs.writeSync = (fd, data, offset, length, position) => { - if (!Buffer.isBuffer(data)) return originalWrite(fd, data, offset, length); + if (fd !== journalFd) return originalWrite(fd, data, offset, length, position); if (chunks++ > 0) throw new Error('write fixture'); return originalWrite(fd, data, offset, Math.min(length, 9), position); }; @@ -340,22 +354,29 @@ test('partial write failure leaves evidence and prevents later append from hidin assert.ok(!fs.existsSync(appendLock(dir))); assert.throws(() => c.append('plan', 'later', {}), error => error.code === 'capsule.truncated_tail'); assert.deepStrictEqual(fs.readFileSync(journal), bytes); - } finally { fs.writeSync = originalWrite; cleanup(dir); } + } finally { fs.writeSync = originalWrite; fs.openSync = originalOpen; cleanup(dir); } }); test('a zero-progress write fails and releases the lock without pretending success', () => { const dir = tempDir('zero-write'); const originalWrite = fs.writeSync; + const originalOpen = fs.openSync; + let journalFd; try { const c = capsule.Capsule.create(dir); - fs.writeSync = () => 0; + fs.openSync = (file, flags, ...args) => { + const fd = originalOpen(file, flags, ...args); + if (file === path.join(dir, capsule.JOURNAL_FILE) && flags === 'a') journalFd = fd; + return fd; + }; + fs.writeSync = (fd, ...args) => fd === journalFd ? 0 : originalWrite(fd, ...args); assert.throws(() => c.append('plan', 'zero', {}), error => error.code === 'capsule.write_failed'); fs.writeSync = originalWrite; assert.strictEqual(capsule.verify(dir).entry_count, 0); assert.ok(!fs.existsSync(appendLock(dir))); assert.strictEqual(c.append('plan', 'later', {}).seq, 0); - } finally { fs.writeSync = originalWrite; cleanup(dir); } + } finally { fs.writeSync = originalWrite; fs.openSync = originalOpen; cleanup(dir); } }); test('fsync failure is an ambiguous acknowledgement and the next append reloads disk', () => { @@ -457,11 +478,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, write: fs.writeFileSync }; + const original = { open: fs.openSync, close: fs.closeSync, stat: fs.lstatSync, unlink: fs.unlinkSync, write: fs.writeSync }; 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 ownedToken = ''; let closed = false; let closes = 0; let inspections = 0; @@ -473,9 +494,9 @@ 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; + fs.writeSync = function(fd, data, offset, length, position) { + const result = original.write.call(this, fd, data, offset, length, position); + if (fd === ownedFd) ownedToken += data.subarray(offset, offset + result).toString('utf8'); return result; }; fs.lstatSync = function(file, ...args) { @@ -516,7 +537,7 @@ for (const scenario of [ fs.closeSync = original.close; fs.lstatSync = original.stat; fs.unlinkSync = original.unlink; - fs.writeFileSync = original.write; + fs.writeSync = original.write; if (scenario.outcome === 'missing') assert.strictEqual(fs.existsSync(lock), false); else assert.strictEqual(fs.readFileSync(lock, 'utf8'), scenario.outcome === 'replacement' ? 'replacement owner' : ownedToken); // Release failure can follow a complete durable append; never infer rollback. @@ -527,7 +548,7 @@ for (const scenario of [ fs.closeSync = original.close; fs.lstatSync = original.stat; fs.unlinkSync = original.unlink; - fs.writeFileSync = original.write; + fs.writeSync = original.write; cleanup(dir); } }); @@ -536,7 +557,7 @@ for (const scenario of [ 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 original = { open: fs.openSync, write: fs.writeSync, close: fs.closeSync }; const failure = Object.assign(new Error('synthetic token initialization failure'), { code: 'EIO' }); let ownedFd; let closed = false; @@ -547,9 +568,9 @@ test('failed ownership-token initialization preserves the primary error and part if (file === lock && flags === 'wx') ownedFd = fd; return fd; }; - fs.writeFileSync = (file, ...args) => { - if (file === ownedFd) throw failure; - return original.write(file, ...args); + fs.writeSync = (fd, ...args) => { + if (fd === ownedFd) throw failure; + return original.write(fd, ...args); }; fs.closeSync = fd => { const result = original.close(fd); @@ -563,12 +584,96 @@ test('failed ownership-token initialization preserves the primary error and part assert.throws(() => capsule.Capsule.open(dir).append('plan', 'blocked', {}), error => error.code === 'capsule.busy'); } finally { fs.openSync = original.open; - fs.writeFileSync = original.write; + fs.writeSync = original.write; fs.closeSync = original.close; cleanup(dir); } }); +test('partial ownership-token failure preserves exact bytes and the primary error', () => { + const dir = tempDir('partial-token'); + const lock = appendLock(dir); + const original = { open: fs.openSync, write: fs.writeSync, close: fs.closeSync }; + const failure = Object.assign(new Error('partial token write fixture'), { code: 'EIO' }); + let ownedFd; + let prefix; + let closes = 0; + 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.writeSync = (fd, data, offset, length, position) => { + if (fd !== ownedFd) return original.write(fd, data, offset, length, position); + if (prefix) throw failure; + const written = original.write(fd, data, offset, Math.min(length, 7), position); + prefix = data.subarray(offset, offset + written).toString('utf8'); + return written; + }; + fs.closeSync = fd => { if (fd === ownedFd) closes += 1; return original.close(fd); }; + assert.throws(() => c.append('plan', 'owner', {}), error => error === failure); + assert.strictEqual(closes, 1); + assert.match(prefix, /^[0-9a-f]{7}$/); + assert.strictEqual(fs.readFileSync(lock, 'utf8'), prefix); + assert.strictEqual(capsule.verify(dir).entry_count, 0); + assert.throws(() => capsule.Capsule.open(dir).append('plan', 'blocked', {}), error => error.code === 'capsule.busy'); + assert.strictEqual(fs.readFileSync(lock, 'utf8'), prefix); + } finally { + fs.openSync = original.open; fs.writeSync = original.write; fs.closeSync = original.close; cleanup(dir); + } +}); + +test('zero-progress ownership-token write refuses before journal mutation and retains its barrier', () => { + const dir = tempDir('zero-token'); + const lock = appendLock(dir); + const original = { open: fs.openSync, write: fs.writeSync, close: fs.closeSync }; + let ownedFd; + let closes = 0; + 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.writeSync = (fd, ...args) => fd === ownedFd ? 0 : original.write(fd, ...args); + fs.closeSync = fd => { if (fd === ownedFd) closes += 1; return original.close(fd); }; + assert.throws(() => c.append('plan', 'owner', {}), error => error.code === 'capsule.write_failed'); + assert.strictEqual(closes, 1); + 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.writeSync = original.write; fs.closeSync = original.close; cleanup(dir); + } +}); + +test('short ownership-token writes finish before the journal is acknowledged', () => { + const dir = tempDir('short-token'); + const lock = appendLock(dir); + const original = { open: fs.openSync, write: fs.writeSync }; + let ownedFd; + let tokenChunks = 0; + 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.writeSync = (fd, data, offset, length, position) => { + if (fd === ownedFd) { tokenChunks += 1; return original.write(fd, data, offset, Math.min(length, 3), position); } + return original.write(fd, data, offset, length, position); + }; + assert.strictEqual(c.append('plan', 'owner', {}).seq, 0); + assert.ok(tokenChunks > 1); + assert.strictEqual(capsule.verify(dir).entry_count, 1); + assert.strictEqual(fs.existsSync(lock), false); + } finally { fs.openSync = original.open; fs.writeSync = original.write; cleanup(dir); } +}); + test('invalid payloads leave journal unchanged and release the append lock', () => { const dir = tempDir(); try {