From 0991cf28e47828cb049ecb4c32fa4c69b50bdb92 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Tue, 18 Aug 2026 10:11:30 -0700 Subject: [PATCH] =?UTF-8?q?fix(locks):=20the=20fence=20keys=20ownership=20?= =?UTF-8?q?on=20pid+hostname=20=E2=80=94=20a=20same-process=20re-open=20ne?= =?UTF-8?q?ver=20fences=20its=20predecessor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plant's integration lane caught it twice: the fence's startedAt-strict comparison turned the documented same-process warn-and-take-over path (two instances in one Node process — the server-restart test pattern, and the shared-default-store pattern across test files) into a flush-killer: the first instance's background flushes latched dead while its own process held the lock ('PID N no longer holds the lock — it is now held by PID N'). Ownership is per-process: pid + hostname. startedAt stays in the lock for observability but not in the fence — it protects nothing (a pid-recycled successor's victim is a dead process that runs no fence checks) and it convicted the innocent. Pinned: a same-process re-open leaves both instances' flushes working; the cross-process eviction pins unchanged. Verified under the lane's exact command: 102/102 files, 850 passed, exit 0. --- src/storage/adapters/fileSystemStorage.ts | 11 +++++++++-- tests/integration/writer-lock-fencing.test.ts | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/storage/adapters/fileSystemStorage.ts b/src/storage/adapters/fileSystemStorage.ts index 8fb2d2f1..b8e2a9af 100644 --- a/src/storage/adapters/fileSystemStorage.ts +++ b/src/storage/adapters/fileSystemStorage.ts @@ -1998,11 +1998,18 @@ export class FileSystemStorage extends BaseStorage { public override async assertWriterFenceHeld(): Promise { if (!this.writerLockInfo) return const current = await this.readWriterLock() + // Ownership is PER-PROCESS: pid + hostname, deliberately NOT startedAt. + // The documented same-process re-open path ("warn and take over" — two + // instances in one Node process, the server-restart test pattern) + // rewrites the lock with a fresh startedAt; fencing the first instance + // on that mismatch latched its background flushes dead while its own + // process held the lock (caught by the plant's integration lane, twice). + // startedAt adds nothing against pid recycling either: a recycled pid's + // victim is a DEAD process — it runs no fence checks. if ( current && current.pid === this.writerLockInfo.pid && - current.hostname === this.writerLockInfo.hostname && - current.startedAt === this.writerLockInfo.startedAt + current.hostname === this.writerLockInfo.hostname ) { return } diff --git a/tests/integration/writer-lock-fencing.test.ts b/tests/integration/writer-lock-fencing.test.ts index 86e79b99..e9f98dac 100644 --- a/tests/integration/writer-lock-fencing.test.ts +++ b/tests/integration/writer-lock-fencing.test.ts @@ -89,6 +89,23 @@ describe('writer-lock fencing', () => { expect(await brain.get(id)).not.toBeNull() }, 120000) + it('the fence does NOT fire on a same-process re-open — the documented warn-and-take-over contract stays benign', async () => { + const dir = mkdtempSync(join(tmpdir(), 'brainy-fence-samepid-')) + dirs.push(dir) + const first = await fsBrain(dir) + await first.add({ data: 'first instance write', type: NounType.Document, metadata: { n: 1 } }) + + // A second instance in the SAME process takes the lock over (fresh + // startedAt) — the pattern server-restart tests use. The first + // instance's background flushes must keep working: same pid + same + // hostname IS ownership. (The plant's integration lane caught the + // startedAt-strict fence latching exactly this shape dead.) + const second = await fsBrain(dir) + await second.add({ data: 'second instance write', type: NounType.Document, metadata: { n: 2 } }) + await expect(first.flush()).resolves.toBeUndefined() + await expect(second.flush()).resolves.toBeUndefined() + }, 120000) + it('THE FENCE: a forced-out writer fails its next flush typed and advances nothing', async () => { const dir = mkdtempSync(join(tmpdir(), 'brainy-fence-evict-')) dirs.push(dir)