OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / repair-loop.test.js

357 lines · javascript ref:
import test from 'brittle'
import { mkdtempSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { AppRegistry } from 'p2p-hiverelay/core/app-registry.js'
import { AppLifecycle } from 'p2p-hiverelay/core/relay-node/app-lifecycle.js'

const REPAIR_BOUND = 16 * 1024 * 1024

function tmpDir () {
  const d = mkdtempSync(join(tmpdir(), 'repair-test-'))
  return { dir: d, cleanup: () => rmSync(d, { recursive: true, force: true }) }
}

function appKey (label) {
  return Buffer.from(label).toString('hex').padEnd(64, '0').slice(0, 64)
}

// Minimal mock of RelayNode for AppLifecycle's repair primitive
function mockNode (registry, opts = {}) {
  return {
    appRegistry: registry,
    swarm: opts.swarm || {
      join: () => {},
      flush: () => Promise.resolve()
    },
    seeder: opts.seeder || null,
    distributedDriveBridge: null,
    seededApps: registry?.apps || new Map(),
    config: opts.config || {},
    storageAdmission: opts.storageAdmission || {
      canAcknowledge: () => true,
      runKeyMutation: (_key, run) => Promise.resolve().then(run)
    }
  }
}

// Mock drive that simulates the Hyperdrive surface AppLifecycle uses.
//
// blobsComplete: drives `_isDriveFullyReplicated`. When false, the
// drive's blob core reports length > 0 but `has(0, length)` returns
// false — simulating the partial-pin failure mode where metadata
// replicated but blob blocks are still missing. Default true so
// existing tests that don't care about the partial-pin path behave
// as if everything is fully replicated.
function mockDrive ({
  version = 0,
  updateOk = true,
  downloadOk = true,
  throwsOnUpdate = false,
  blobsComplete = true,
  blobLength = 8
} = {}) {
  let blobBlocksComplete = blobsComplete
  const range = (blob = false) => ({
    async done () {
      if (!downloadOk || (blob && !blobBlocksComplete)) throw new Error('incomplete range')
    },
    async downloaded () {
      if (!downloadOk || (blob && !blobBlocksComplete)) throw new Error('incomplete range')
    },
    destroy () {}
  })
  const snapshot = (core, blob = false) => ({
    fork: core.fork,
    length: core.length,
    byteLength: core.byteLength,
    async ready () {},
    download: () => range(blob),
    async close () {}
  })
  const metaCore = {
    fork: 0,
    length: Math.max(0, version),
    byteLength: 1024,
    async update () {},
    snapshot () { return snapshot(this) }
  }
  const blobCore = {
    fork: 0,
    length: blobLength,
    byteLength: blobLength * 1024,
    async update () {},
    has: async () => blobBlocksComplete,
    snapshot () { return snapshot(this, true) }
  }
  const drive = {
    closed: false,
    closing: false,
    version,
    discoveryKey: Buffer.alloc(32, 0xab),
    db: {
      core: metaCore
    },
    update: async () => {
      if (throwsOnUpdate) throw new Error('boom')
      if (!updateOk) {
        await new Promise(resolve => {
          const timer = setTimeout(resolve, 100_000)
          if (timer.unref) timer.unref()
        })
      }
      drive.version = Math.max(drive.version, 1)
      metaCore.length = drive.version
    },
    download: () => range(true),
    blobs: {
      core: blobCore
    },
    // Test helper: flip the partial-pin signal mid-test so we can model
    // "first repair pass pulls some blocks, second pass pulls the rest."
    _setBlobsComplete: (v) => { blobBlocksComplete = v }
  }
  return drive
}

test('repair: returns false when drive missing', async (t) => {
  const { dir, cleanup } = tmpDir(); t.teardown(cleanup)
  const reg = new AppRegistry(dir)
  reg.set(appKey('aa'), { type: 'app' })
  const lifecycle = new AppLifecycle(mockNode(reg))
  const ok = await lifecycle.repairUnanchored(appKey('aa'))
  t.is(ok, false)
})

test('repair: returns true when already anchored', async (t) => {
  const { dir, cleanup } = tmpDir(); t.teardown(cleanup)
  const reg = new AppRegistry(dir)
  const drive = mockDrive({ version: 5 })
  reg.set(appKey('bb'), { type: 'app', drive })
  reg.setAnchored(appKey('bb'), 5)
  const lifecycle = new AppLifecycle(mockNode(reg))
  const ok = await lifecycle.repairUnanchored(appKey('bb'))
  t.is(ok, true)
})

test('repair: succeeds when drive update yields version > 0', async (t) => {
  const { dir, cleanup } = tmpDir(); t.teardown(cleanup)
  const reg = new AppRegistry(dir)
  const drive = mockDrive({ version: 0, updateOk: true })
  reg.set(appKey('cc'), { type: 'app', drive, discoveryKey: drive.discoveryKey, maxStorage: REPAIR_BOUND })
  const lifecycle = new AppLifecycle(mockNode(reg))
  const ok = await lifecycle.repairUnanchored(appKey('cc'), { updateTimeout: 500, downloadTimeout: 500 })
  t.is(ok, true, 'returns true')
  const e = reg.get(appKey('cc'))
  t.is(e.anchored, true, 'entry marked anchored')
  t.ok(e.anchoredLength > 0)
})

test('repair: returns false on update timeout', async (t) => {
  const { dir, cleanup } = tmpDir(); t.teardown(cleanup)
  const reg = new AppRegistry(dir)
  const drive = mockDrive({ version: 0, updateOk: false })
  reg.set(appKey('dd'), { type: 'app', drive, discoveryKey: drive.discoveryKey, maxStorage: REPAIR_BOUND })
  const lifecycle = new AppLifecycle(mockNode(reg))
  const ok = await lifecycle.repairUnanchored(appKey('dd'), { updateTimeout: 200, downloadTimeout: 200 })
  t.is(ok, false)
  const e = reg.get(appKey('dd'))
  t.is(e.anchored, false, 'entry stays unanchored')
})

test('repair: returns false on update throw', async (t) => {
  const { dir, cleanup } = tmpDir(); t.teardown(cleanup)
  const reg = new AppRegistry(dir)
  const drive = mockDrive({ version: 0, throwsOnUpdate: true })
  reg.set(appKey('ee'), { type: 'app', drive, discoveryKey: drive.discoveryKey, maxStorage: REPAIR_BOUND })
  const lifecycle = new AppLifecycle(mockNode(reg))
  const ok = await lifecycle.repairUnanchored(appKey('ee'), { updateTimeout: 500, downloadTimeout: 500 })
  t.is(ok, false)
})

test('runRepairPass: aggregates checked / repaired / stillUnanchored', async (t) => {
  const { dir, cleanup } = tmpDir(); t.teardown(cleanup)
  const reg = new AppRegistry(dir)
  // 1 already-anchored (skipped)
  const d1 = mockDrive({ version: 3 })
  reg.set(appKey('a1'), { type: 'app', drive: d1, discoveryKey: d1.discoveryKey })
  reg.setAnchored(appKey('a1'), 3)
  // 1 will-repair
  const d2 = mockDrive({ version: 0, updateOk: true })
  reg.set(appKey('a2'), { type: 'app', drive: d2, discoveryKey: d2.discoveryKey })
  // 1 won't-repair (timeout)
  const d3 = mockDrive({ version: 0, updateOk: false })
  reg.set(appKey('a3'), { type: 'app', drive: d3, discoveryKey: d3.discoveryKey })

  const lifecycle = new AppLifecycle(mockNode(reg))
  // Override default timeouts for fast tests
  lifecycle.repairUnanchored = async function (key) {
    if (key === appKey('a2')) {
      reg.setAnchored(key, 1)
      return true
    }
    return false
  }
  const result = await lifecycle.runRepairPass({ maxConcurrent: 2 })
  t.is(result.checked, 2, 'a1 skipped (anchored)')
  t.is(result.repaired, 1, 'a2 repaired')
  t.is(result.stillUnanchored, 1, 'a3 still unanchored')
})

test('runRepairPass: respects budget', async (t) => {
  const { dir, cleanup } = tmpDir(); t.teardown(cleanup)
  const reg = new AppRegistry(dir)
  for (let i = 0; i < 10; i++) {
    const d = mockDrive()
    reg.set(appKey('app' + i), { type: 'app', drive: d, discoveryKey: d.discoveryKey })
  }
  const lifecycle = new AppLifecycle(mockNode(reg))
  lifecycle.repairUnanchored = async () => false // all fail, but counted
  const result = await lifecycle.runRepairPass({ budget: 3 })
  t.is(result.checked, 3, 'budget honored')
})

test('runRepairPass: skips entries without drive', async (t) => {
  const { dir, cleanup } = tmpDir(); t.teardown(cleanup)
  const reg = new AppRegistry(dir)
  reg.set(appKey('nodrive'), { type: 'app' }) // no drive instance
  const d = mockDrive()
  reg.set(appKey('hasdrive'), { type: 'app', drive: d, discoveryKey: d.discoveryKey })

  const lifecycle = new AppLifecycle(mockNode(reg))
  lifecycle.repairUnanchored = async () => false
  const result = await lifecycle.runRepairPass()
  t.is(result.checked, 1, 'only entry with drive checked')
})

test('repair: refuses to anchor when an exact core snapshot is unavailable', async (t) => {
  const { dir, cleanup } = tmpDir(); t.teardown(cleanup)
  const reg = new AppRegistry(dir)
  const drive = mockDrive({ version: 5, blobsComplete: true })
  drive.blobs.core.snapshot = undefined
  reg.set(appKey('no-snapshot'), {
    type: 'app',
    drive,
    discoveryKey: drive.discoveryKey,
    maxStorage: REPAIR_BOUND
  })
  const lifecycle = new AppLifecycle(mockNode(reg))

  const ok = await lifecycle.repairUnanchored(appKey('no-snapshot'), {
    updateTimeout: 500,
    downloadTimeout: 500
  })
  t.is(ok, false, 'missing pinned blob proof fails closed')
  t.is(reg.get(appKey('no-snapshot')).anchored, false)
})

// ─── Partial-pin self-heal (regression coverage for the silent
//     metadata-only "anchored" failure mode patched 2026-05-22) ──────
//
// Before the fix, an entry whose metadata replicated but whose blob
// core still had missing blocks would get marked anchored on the
// strength of drive.version > 0. The periodic repair pass then
// skipped it, so the gap never closed and end users hit indistinguishable
// -from-network-down hangs. See docs/AUTO-HEAL-ROOT-CAUSE-2026-05-22.md.

test('repair: partial pin (metadata replicated, blocks missing) stays unanchored', async (t) => {
  const { dir, cleanup } = tmpDir(); t.teardown(cleanup)
  const reg = new AppRegistry(dir)
  // Drive replies "metadata synced" but blob core has gaps.
  const drive = mockDrive({ version: 5, updateOk: true, downloadOk: true, blobsComplete: false })
  reg.set(appKey('partial'), { type: 'app', drive, discoveryKey: drive.discoveryKey, maxStorage: REPAIR_BOUND })
  const lifecycle = new AppLifecycle(mockNode(reg))
  const ok = await lifecycle.repairUnanchored(appKey('partial'), { updateTimeout: 500, downloadTimeout: 500 })
  t.is(ok, false, 'repair reports failure on partial pin (would have returned true before the fix)')
  const e = reg.get(appKey('partial'))
  t.is(e.anchored, false, 'entry stays unanchored on partial pin')
})

test('repair: partial pin gets anchored once all blob blocks land', async (t) => {
  const { dir, cleanup } = tmpDir(); t.teardown(cleanup)
  const reg = new AppRegistry(dir)
  const drive = mockDrive({ version: 5, updateOk: true, downloadOk: true, blobsComplete: false })
  reg.set(appKey('eventually'), { type: 'app', drive, discoveryKey: drive.discoveryKey, maxStorage: REPAIR_BOUND })
  const lifecycle = new AppLifecycle(mockNode(reg))

  // First pass: blocks missing → not anchored
  let ok = await lifecycle.repairUnanchored(appKey('eventually'), { updateTimeout: 500, downloadTimeout: 500 })
  t.is(ok, false)
  t.is(reg.get(appKey('eventually')).anchored, false)

  // Simulate the next repair tick: peer transmitted the missing blocks.
  drive._setBlobsComplete(true)

  ok = await lifecycle.repairUnanchored(appKey('eventually'), { updateTimeout: 500, downloadTimeout: 500 })
  t.is(ok, true, 'repair anchors once blob core is fully present')
  t.is(reg.get(appKey('eventually')).anchored, true)
})

test('_isDriveFullyReplicated: empty blob core (metadata-only drive) counts as anchored', async (t) => {
  const { dir, cleanup } = tmpDir(); t.teardown(cleanup)
  const reg = new AppRegistry(dir)
  const lifecycle = new AppLifecycle(mockNode(reg))
  const drive = mockDrive({ version: 1, blobLength: 0 })
  const ok = await lifecycle._isDriveFullyReplicated(drive)
  t.is(ok, true, 'no blob blocks needed → vacuously fully replicated')
})

test('_isDriveFullyReplicated: closed drive is not anchored', async (t) => {
  const { dir, cleanup } = tmpDir(); t.teardown(cleanup)
  const reg = new AppRegistry(dir)
  const lifecycle = new AppLifecycle(mockNode(reg))
  const drive = mockDrive({ version: 1, blobsComplete: true })
  drive.closed = true
  const ok = await lifecycle._isDriveFullyReplicated(drive)
  t.is(ok, false)
})

test('_isDriveFullyReplicated: drive without blob layer is not anchored', async (t) => {
  const { dir, cleanup } = tmpDir(); t.teardown(cleanup)
  const reg = new AppRegistry(dir)
  const lifecycle = new AppLifecycle(mockNode(reg))
  const drive = mockDrive({ version: 1 })
  drive.blobs = null // simulate hyperdrive whose blob layer never loaded
  const ok = await lifecycle._isDriveFullyReplicated(drive)
  t.is(ok, false, 'cannot serve content we have no blob core for')
})

test('runRepairPass: re-queues entries the periodic check downgraded from anchored', async (t) => {
  const { dir, cleanup } = tmpDir(); t.teardown(cleanup)
  const reg = new AppRegistry(dir)
  const d1 = mockDrive({ version: 3, blobsComplete: false })
  reg.set(appKey('p1'), { type: 'app', drive: d1, discoveryKey: d1.discoveryKey })
  // Simulate the situation post-_runAnchorCheck on a stale anchored entry
  // (this is the path the fix enables: the periodic check downgrades the
  // entry from anchored:true → false when it detects partial-pin, and
  // runRepairPass MUST re-queue it).
  reg.setAnchored(appKey('p1'), 3)
  t.is(reg.get(appKey('p1')).anchored, true, 'starts anchored (pre-detection)')
  reg.clearAnchored(appKey('p1'), 'simulated partial-pin detection')
  t.is(reg.get(appKey('p1')).anchored, false, 'periodic check cleared anchored')

  const lifecycle = new AppLifecycle(mockNode(reg))
  let repairCalls = 0
  lifecycle.repairUnanchored = async () => {
    repairCalls++
    return false // simulate "still partial, blocks not all here yet"
  }

  const r = await lifecycle.runRepairPass()
  t.is(r.checked, 1, 'previously-anchored entry is requeued after clearAnchored')
  t.is(repairCalls, 1, 'repairUnanchored invoked')
})

test('catalogForBroadcast includes anchored field', (t) => {
  const { dir, cleanup } = tmpDir(); t.teardown(cleanup)
  const reg = new AppRegistry(dir)
  reg.set(appKey('a'), { type: 'app' })
  reg.set(appKey('b'), { type: 'app' })
  reg.setAnchored(appKey('a'), 5)
  const broadcast = reg.catalogForBroadcast()
  const a = broadcast.find(x => x.appKey === appKey('a'))
  const b = broadcast.find(x => x.appKey === appKey('b'))
  t.is(a.anchored, true, 'a is anchored')
  t.is(b.anchored, false, 'b is not anchored')
})