From 6a06ee632f17cdd7f09a102de194cbdbfca7b700 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Thu, 16 Jul 2026 10:49:36 +0100 Subject: [PATCH 1/2] test(core-manager): failing test for purged cores reopening in repair mode purgeCore deletes all tree nodes and blocks but leaves the core's head record (fork, length, root hash) in storage. When the same storage is re-opened - as happens when a device re-joins a project after leaving, which re-adds the same core keys - hypercore finds a header claiming length > 0 with no merkle roots and puts the core in repair mode: the replicator becomes push-only and never sends a Synchronize, so the core silently never syncs for the rest of the session. Peers waiting on that core's handshake wait forever. The new test purges a downloaded core, re-opens the same storage, and asserts a block can be re-downloaded. It times out on the current implementation. --- test/core-manager.js | 46 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/test/core-manager.js b/test/core-manager.js index 0e08263b..fd61819f 100644 --- a/test/core-manager.js +++ b/test/core-manager.js @@ -18,6 +18,8 @@ import { Transform } from 'streamx' import { waitForCores } from './helpers/core-manager.js' import { drizzle } from 'drizzle-orm/better-sqlite3' import { createCore } from './helpers/create-core.js' +import { temporaryDirectory } from 'tempy' +import fsPromises from 'node:fs/promises' /** @import { Namespace } from '../src/types.js' */ test('project creator auth core has project key', async function (t) { @@ -555,3 +557,47 @@ function latencyStream(delay = 0) { }, }) } + +test('deleteOthersData leaves purged cores usable after re-opening storage', async function (t) { + const projectKey = randomBytes(32) + const cm1 = createCoreManager(t, { projectKey }) + + // cm2 gets stable identity, storage and db so it can be re-opened, as + // happens when a device re-joins a project after leaving + const rootKey = randomBytes(16) + const db = drizzle(new Sqlite(':memory:')) + const storage = temporaryDirectory() + t.after(() => fsPromises.rm(storage, { recursive: true, force: true })) + + const cm2 = createCoreManager(t, { projectKey, rootKey, db, storage }, false) + + const writer = cm1.getWriterCore('data') + await writer.core.append(['a', 'b', 'c']) + + cm2.addCore(writer.key, 'data') + const rep1 = await replicate(cm1, cm2) + const core2 = cm2.getCoreByKey(writer.key) + assert(core2, 'core was added') + await core2.download({ start: 0, end: 3 }).done() + await rep1.destroy() + + await cm2.deleteOthersData('data') + await cm2.close() + + const cm2reopened = createCoreManager(t, { projectKey, rootKey, db, storage }) + cm2reopened.addCore(writer.key, 'data') + const core2reopened = cm2reopened.getCoreByKey(writer.key) + assert(core2reopened, 'core was re-added') + await core2reopened.ready() + + const rep2 = await replicate(cm1, cm2reopened) + t.after(() => rep2.destroy()) + + const block = await Promise.race([ + core2reopened.get(0), + delay(5000).then(() => { + throw new Error('timed out re-syncing the purged core') + }), + ]) + assert.deepEqual(block, Buffer.from('a'), 're-downloaded purged block') +}) From 345b6109dbb653095836240218987107e26d886c Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Thu, 16 Jul 2026 10:49:37 +0100 Subject: [PATCH 2/2] fix(core-manager): delete the head record when purging a core purgeCore deletes all tree nodes and blocks but left the core's head record (fork, length, root hash) in storage. When the same storage was re-opened - a device re-joining a project after leaving re-adds the same core keys - hypercore found a header claiming length > 0 with no merkle roots and put the core in repair mode: push-only, never sending a Synchronize, so the core silently never synced for the rest of the session, and peers gating on its handshake waited forever. Delete the head record too, so a purged core re-opens as a normal empty core and re-syncs from peers. --- src/core-manager/index.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/core-manager/index.js b/src/core-manager/index.js index 4e4c028c..c75a2652 100644 --- a/src/core-manager/index.js +++ b/src/core-manager/index.js @@ -522,6 +522,11 @@ async function purgeCore(core) { const tx = privateCore.storage.write() tx.deleteTreeNodeRange(0, -1) tx.deleteBlockRange(0, -1) + // Also delete the head record (fork, length, root hash): a header claiming + // length > 0 with no merkle roots in storage puts the core in hypercore's + // repair mode on next open, in which it never sends a Synchronize and so + // never syncs again + tx.deleteHead() // @ts-ignore Private methods on storage privateCore.bitfield.clear(tx) await tx.flush()