// 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')
})