OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / test / holder-after-knock.test.js

124 lines · javascript ref:
// A READ-ONLY HOLDER of a feed somebody has knocked on.
//
// ⛔ THE INCIDENT (2026-10-12). ap-bridge holds registered users' feeds read-only, opened with
// `optimistic: false` (FeedHolder passes no opts, and Feed defaults the doorbell off). A stranger had
// knocked on fleeky's feed: one optimistic block from a writer that is not on the roster. On autobee
// 2.1.1 the holder pinned a core for days. `_bumpPendingWriters` could not apply that block (writer
// not added, base not optimistic), returned `true` without `removePending()`, and `_drain` reselected
// the same writer forever. Measured live: 110% CPU, drains frozen, the stranger's writer at
// isAdded:false with 1 optimistic block. Fixed upstream in autobee 2.5.1 (ed166f4).
//
// ⛔ AND A SECOND DEFECT, WHICH THE UPGRADE DOES NOT FIX. The owner's apply ACKS a knock, so on the
// owner it sticks, and everything the owner writes afterwards links past it. A holder that refuses
// optimistic blocks cannot apply the knock, so every later owner entry is unlinkable on that holder.
// Live, fleeky's writer sat at 106 of 113 applied: the bridge never saw the newest posts. So:
//   1. the holder's update() finishes (the spin, fixed by autobee ≥2.5.1);
//   2. a holder opened like a favela node (optimistic:true) follows the owner past the knock.

import test from 'brittle'
import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import Corestore from 'corestore'
import { RecordStore } from '../lib/record-store.js'
import { Feed } from '../lib/feed-base.js'
import { POST } from '../lib/peer.js'
import { attest, identity } from './helpers.js'

// ⛔ A knock proves which identity is knocking (feed.js applyKnock): the binder ties the knocking
// writer core to the stranger's identity root.
const STRANGER = await (await identity()).device()
const asStranger = (writerKeyHex) => STRANGER.bind(writerKeyHex)

const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-holder-knock-'))

function pipe (a, b) {
  const s1 = a.replicate(true)
  const s2 = b.replicate(false)
  s1.pipe(s2).pipe(s1)
  return () => { s1.destroy(); s2.destroy() }
}

async function until (fn, { timeout = 15000 } = {}) {
  const deadline = Date.now() + timeout
  while (Date.now() < deadline) {
    const v = await fn()
    if (v) return v
    await new Promise((r) => setTimeout(r, 25))
  }
  return null
}

// update() bounded, because the defect under test is an update that never returns.
const settles = (p, ms) => Promise.race([p.then(() => true), new Promise((r) => setTimeout(() => r(false), ms))])

async function mkFeed (store, key, records, opts = {}) {
  const f = new Feed(store, key, { store: new RecordStore(records), ...opts })
  await f.ready()
  return f
}

async function owned (records) {
  const f = await mkFeed(new Corestore(tmp()), null, records, { optimistic: true })
  await f.addDevice(await attest(f))
  await f.update()
  return f
}

// alice owns a feed; bob knocks on it; alice then posts. Returns alice and her records dir.
async function knockedFeed (t) {
  const records = tmp()
  const alice = await owned(records)
  const bob = await owned(tmp())
  const aliceOnBob = await mkFeed(new Corestore(tmp()), alice.key, records, { optimistic: true })
  const unpipe = pipe(alice, aliceOnBob)

  await until(async () => { await aliceOnBob.update(); return (await aliceOnBob.devices()).length > 0 })
  await aliceOnBob.knock(bob.key, asStranger)
  const knocked = await until(async () => { await alice.update(); return (await alice.knocks()).length })
  t.ok(knocked, 'setup: the stranger\'s knock landed in the owner\'s feed')

  await alice.put(POST, 'after-knock', { frontmatter: { createdAt: '2026-10-12T00:00:00Z' }, body: 'written after the knock' })
  await alice.update()

  return { alice, records, cleanup: async () => { unpipe(); await bob.close(); await aliceOnBob.close() } }
}

test('★ a read-only holder (optimistic:false, as ap-bridge opens it) is not wedged by a knock', async (t) => {
  const { alice, records, cleanup } = await knockedFeed(t)

  const holder = await mkFeed(new Corestore(tmp()), alice.key, records, { optimistic: false })
  const unpipe = pipe(alice, holder)
  t.teardown(async () => { unpipe(); await cleanup(); await alice.close(); await settles(holder.close(), 3000) })

  // Let the knock replicate to the holder before asking it to settle.
  await until(async () => (await settles(holder.update(), 2000)) && (await holder.devices()).length > 0, { timeout: 10000 })

  // A — the spin. Red on 2.1.1, green on 2.12.1.
  t.ok(await settles(holder.update(), 5000), 'A: the holder\'s update() returns (2.1.1 reselects the knock forever)')

  // B is NOT asserted here, because with optimistic:false it cannot hold. MEASURED on 2.12.1: the
  // holder settles but never sees `after-knock`. It refuses the knock the owner acked, so it can never
  // link past it, and it computes a different view of the same log. That is a configuration defect in
  // whoever opens a holder this way (ap-bridge's FeedHolder did), and the next test is its guard.
})

// ★ The configuration every favela node already uses for a held feed (node.js: the doorbell is on by
// default). A holder must apply the log under the SAME rules as the owner, or it computes a different
// view of it: the owner acks the knock, so a holder that refuses optimistic blocks can never link past
// it.
test('★ a holder opened the way favela nodes open feeds (optimistic:true) follows the owner past a knock', async (t) => {
  const { alice, records, cleanup } = await knockedFeed(t)

  const holder = await mkFeed(new Corestore(tmp()), alice.key, records, { optimistic: true })
  const unpipe = pipe(alice, holder)
  t.teardown(async () => { unpipe(); await cleanup(); await alice.close(); await settles(holder.close(), 3000) })

  const seen = await until(async () => {
    if (!(await settles(holder.update(), 2000))) return null
    return (await holder.pointers(POST)).some((p) => p.name === 'after-knock') || null
  }, { timeout: 10000 })
  t.ok(seen, 'the holder sees what the owner wrote after the knock')
  t.absent(holder.writable && (await holder.devices()).length > 1, 'and holding grants it nothing on the roster')
})