/**
* Restart persistence integration test.
*
* Pins the regression class where a clean stop()/start() cycle (operator
* restart, systemd SIGTERM, or in-process SelfHeal restart) silently lost
* all seeded apps. Three coupled defects produced it:
*
* 1. stop() called unseedApp() on every app, which persisted a registry
* delete — erasing entries from disk.
* 2. HyperGateway.close() threw (DriveCache isn't iterable), leaking the
* HTTP server so the next start() hit EADDRINUSE.
* 3. start() recreates the corestore on restart, but the AppRegistry's
* Hyperbee still pointed at the closed old store, so reseed read
* nothing even when entries survived on disk.
*
* The contract these tests assert: apps seeded before a restart are still
* seeded after it.
*/
import test from 'brittle'
import createTestnet from '@hyperswarm/testnet'
import Hyperdrive from 'hyperdrive'
import { RelayNode } from 'p2p-hiverelay/core/relay-node/index.js'
import b4a from 'b4a'
import { randomBytes } from 'crypto'
import { mkdir, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
const TEST_MAX_STORAGE_BYTES = 64 * 1024 * 1024
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms))
async function makeNode (dir, bootstrap, extra = {}) {
await mkdir(dir, { recursive: true })
return new RelayNode({
storage: dir,
bootstrapNodes: bootstrap,
enableAPI: false,
enableRelay: false,
enableSeeding: true,
enableServices: false,
enableNetworkDiscovery: false,
enableHolesail: false,
shutdownTimeoutMs: 10_000,
...extra
})
}
async function within (promise, timeoutMs, label) {
let timer = null
try {
return await Promise.race([
promise,
new Promise((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(label + ' timed out after ' + timeoutMs + 'ms')), timeoutMs)
})
])
} finally {
if (timer) clearTimeout(timer)
}
}
async function waitFor (fn, timeoutMs = 30_000, intervalMs = 50) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (await fn()) return true
await sleep(intervalMs)
}
return false
}
function hasCompletePinnedProof (entry) {
return entry?.anchored === true &&
entry.anchoredLength === entry.storageProvedDriveVersion &&
Number.isSafeInteger(entry.storageProvedDriveVersion) && entry.storageProvedDriveVersion > 0 &&
Number.isSafeInteger(entry.storageProvedMetaLength) && entry.storageProvedMetaLength >= 0 &&
Number.isSafeInteger(entry.storageProvedBlobLength) && entry.storageProvedBlobLength >= 0 &&
Number.isSafeInteger(entry.storageProvedTotalBytes) && entry.storageProvedTotalBytes >= 0 &&
Number.isSafeInteger(entry.storageProvedMetaFork) && entry.storageProvedMetaFork >= 0 &&
Number.isSafeInteger(entry.storageProvedBlobFork) && entry.storageProvedBlobFork >= 0 &&
Array.isArray(entry.downloadSnapshotCores) && entry.downloadSnapshotCores.length === 2
}
async function assertPinnedEntries (t, node, keys, label) {
for (const key of keys) {
t.ok(await waitFor(() => hasCompletePinnedProof(node.appRegistry.get(key))),
`${label}: ${key.slice(0, 8)} has complete persisted proof + live snapshots`)
t.is(node.appRegistry.get(key)?.maxStorage, TEST_MAX_STORAGE_BYTES,
`${label}: ${key.slice(0, 8)} preserves exact finite bound`)
t.ok(node.storageAdmission.canAcknowledge(`drive:${key}`),
`${label}: ${key.slice(0, 8)} has authority ACK`)
}
}
async function startPublisher (baseDir, bootstrap, count, label) {
const publisher = await makeNode(join(baseDir, 'publisher'), bootstrap)
const drives = []
const discovery = []
const keys = []
await publisher.start()
for (let i = 0; i < count; i++) {
// Corestore 7: session() no longer inherits the parent namespace, so
// namespace(name).session() collapses every drive onto the default
// namespace's shared 'db' core and the second Hyperdrive's exclusive
// open deadlocks. namespace(name) already returns the scoped session.
const drive = new Hyperdrive(publisher.store.namespace(`${label}-${i}`))
await drive.ready()
await drive.put('/index.bin', randomBytes(64 * 1024))
drives.push(drive)
keys.push(b4a.toString(drive.key, 'hex'))
discovery.push(publisher.swarm.join(drive.discoveryKey, { server: true, client: true }))
}
await publisher.swarm.flush()
return { publisher, drives, discovery, keys }
}
async function stopPublisher (fixture) {
for (const handle of fixture.discovery) {
try { await handle.destroy() } catch {}
}
for (const drive of fixture.drives) {
try { await drive.close() } catch {}
}
await fixture.publisher.stop()
}
async function seedAll (node, keys) {
for (const key of keys) {
await within(
node.seedApp(key, { maxStorage: TEST_MAX_STORAGE_BYTES }),
30_000,
'authoritative seed ' + key.slice(0, 8)
)
}
}
test('seeded apps survive a stop()/start() cycle (in-process restart)', async (t) => {
const id = randomBytes(4).toString('hex')
const baseDir = join(tmpdir(), `hiverelay-restart-${id}`)
const dir = join(baseDir, 'consumer')
const testnet = await createTestnet(2)
const node = await makeNode(dir, testnet.bootstrap)
let source = null
t.teardown(async () => {
try { await node.stop() } catch {}
if (source) try { await stopPublisher(source) } catch {}
try { await testnet.destroy() } catch {}
try { await rm(baseDir, { recursive: true, force: true }) } catch {}
})
source = await startPublisher(baseDir, testnet.bootstrap, 3, 'in-process')
await node.start()
t.is(new Set(source.keys).size, 3, 'publisher authored three distinct drive keys')
await seedAll(node, source.keys)
await assertPinnedEntries(t, node, source.keys, 'before in-process restart')
t.is(node.seededApps.size, 3, 'three apps seeded before restart')
await node.stop()
await node.start()
await assertPinnedEntries(t, node, source.keys, 'after in-process restart')
t.is(node.seededApps.size, 3, 'all three apps repopulated after restart')
for (const k of source.keys) {
t.ok(node.seededApps.has(k), `app ${k.slice(0, 8)} present after restart`)
}
})
test('apps survive a fresh-process restart (new RelayNode, same storage)', async (t) => {
const id = randomBytes(4).toString('hex')
const baseDir = join(tmpdir(), `hiverelay-restart-fresh-${id}`)
const dir = join(baseDir, 'consumer')
const testnet = await createTestnet(2)
let node = await makeNode(dir, testnet.bootstrap)
let source = null
t.teardown(async () => {
try { await node.stop() } catch {}
if (source) try { await stopPublisher(source) } catch {}
try { await testnet.destroy() } catch {}
try { await rm(baseDir, { recursive: true, force: true }) } catch {}
})
source = await startPublisher(baseDir, testnet.bootstrap, 2, 'fresh-process')
await node.start()
t.is(new Set(source.keys).size, 2, 'publisher authored two distinct drive keys')
await seedAll(node, source.keys)
await assertPinnedEntries(t, node, source.keys, 'before fresh-process restart')
await node.stop()
// Simulate a process restart: brand-new RelayNode over the same storage.
node = await makeNode(dir, testnet.bootstrap)
await node.start()
await assertPinnedEntries(t, node, source.keys, 'after fresh-process restart')
t.is(node.seededApps.size, 2, 'apps reloaded by a fresh process from disk')
for (const k of source.keys) {
t.ok(node.seededApps.has(k), `app ${k.slice(0, 8)} reloaded by fresh process`)
}
})