OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge commits / f27c08a

0.10.12: hold a feed under the SAME rules as its owner
⛔ THE BUG (measured 2026-10-12). FeedHolder opened every held feed with favela-peer's Feed default, `optimistic: false`. Every favela node opens feeds with `optimistic: true` (favela-peer node.js: "the doorbell is on by default"), and the owner's apply ACKS a stranger's knock, so everything the owner writes afterwards links past it. A holder that refuses optimistic blocks can never apply the knock: the bridge and the owner computed different views of one log. On fleeky's feed this stranded the bridge at 106 of 113 applied. The newest posts never reached the fediverse, and nothing logged a thing. On autobee 2.1.1 the same knock also pinned a core for days (the reselect loop, fixed by favela-peer 0.0.59's autobee 2.12.1). That is the half people noticed. This is the half that was losing posts. Measured on the live feed over the real swarm, autobee 2.12.1: FeedHolder as shipped (optimistic:false) → owner writer 106/113, stuck opened like a favela node (optimistic:true) → 113/113 `optimistic: true` grants the bridge nothing. It never appends, and apply still decides every optimistic block exactly as the owner's apply does. The guard goes through FeedHolder.hold() itself, the place the option is chosen. It ran red for the right reason first (the knock landed, the bridge never saw the post written after it) and is green with the fix, in 415 ms. 364/364 tests, 1280 asserts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
f27c08aa847d5b0da079daec090b049da4a5af63
by plankatron <fleeky@gmail.com> · 2026-10-12T20:27:02.000Z

Changed files (3) +72 -2

lib/feed-holder.js +9 -1
……@@ -165,7 +165,15 @@ export class FeedHolder {
165165 if (this._held.has(id)) return this._held.get(id)
166166
167167 const feed = new Feed(this.corestore.namespace('feed:' + id), b4a.from(id, 'hex'), {
168 store: new RecordStore(join(this.storage, 'records', id), { overlay: this.overlay })
168 store: new RecordStore(join(this.storage, 'records', id), { overlay: this.overlay }),
169 // ⛔ THE SAME RULES AS THE OWNER, or a different view of the same log. Every favela node opens
170 // feeds with the doorbell ON (favela-peer node.js), and the owner's apply acks a stranger's
171 // knock, so everything the owner writes afterwards links past it. Feed's default is OFF, and a
172 // holder that refuses optimistic blocks can never apply the knock — measured 2026-10-12 on
173 // fleeky's feed: stuck at 106 of 113, the newest posts never reaching the fediverse (on autobee
174 // 2.1.1 it also pinned a core). This grants the bridge nothing: it never appends, and apply
175 // still decides every optimistic block exactly as the owner's does.
176 optimistic: true
169177 })
170178 await feed.ready()
171179
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "ap-bridge",
3 "version": "0.10.11",
3 "version": "0.10.12",
44 "description": "ActivityPub membrane \u2014 a second projection of the self-custody atproto peer",
55 "type": "module",
66 "main": "index.js",
test/feed-holder.test.js +62 -0
……@@ -137,3 +137,65 @@ test('⛔⛔ watch() fires when a held feed advances — real append, real pipe'
137137 const { rows } = await new FavelaFeed({ feed: bridge }).ownPosts()
138138 t.ok(rows.some((r) => r.name === '01k2later'), 'the record the trigger was announcing')
139139})
140
141// ⛔⛔ A KNOCK MUST NOT STRAND THE BRIDGE BEHIND THE OWNER (2026-10-12).
142//
143// FeedHolder opened every held feed with favela-peer's default of `optimistic: false`. Every favela
144// node opens feeds with `optimistic: true` (favela-peer node.js: "the doorbell is on by default"), and
145// the owner's apply ACKS a stranger's knock. So the owner's log links past the knock and a holder that
146// refuses optimistic blocks can never follow it: the two compute different views of one log. MEASURED
147// live on fleeky's feed: the bridge stuck at 106 of 113 applied, the newest posts never reaching the
148// fediverse. (On autobee 2.1.1 the same knock also pinned a core; that half is favela-peer's
149// holder-after-knock.test.js.)
150//
151// So this goes through FeedHolder.hold() itself — the place the option is chosen — and asserts the
152// bridge sees what the owner wrote AFTER a stranger knocked.
153test('⛔⛔ a knock on a held feed does not strand the bridge behind the owner', async (t) => {
154 const { FeedHolder } = await import('../lib/feed-holder.js')
155 const records = tmp()
156
157 // The owner, opened the way a favela node opens it.
158 const owner = new Feed(new Corestore(tmp()), null, { store: new RecordStore(records), optimistic: true })
159 await owner.ready()
160 await owner.addDevice(await attest(owner))
161 await owner.update()
162
163 // A stranger knocks from their own machine: a reader of the owner's log, a writer of nothing in it.
164 const stranger = new Feed(new Corestore(tmp()), null, { store: new RecordStore(tmp()), optimistic: true })
165 await stranger.ready()
166 const ownerOnStranger = new Feed(new Corestore(tmp()), owner.key, { store: new RecordStore(records), optimistic: true })
167 await ownerOnStranger.ready()
168 const unpipeStranger = pipe(owner, ownerOnStranger)
169
170 await until(async () => { await ownerOnStranger.update(); return (await ownerOnStranger.devices()).length > 0 }, { timeout: 10000 })
171 await ownerOnStranger.knock(stranger.key)
172 t.ok(await until(async () => { await owner.update(); return (await owner.knocks()).length }, { timeout: 10000 }),
173 'setup: the stranger\'s knock landed in the owner\'s feed')
174
175 await owner.put('post', 'after-knock', { frontmatter: { createdAt: '2026-10-12T00:00:00Z' }, body: 'written after the knock\n' })
176 await owner.update()
177
178 // The bridge holds the owner's feed through FeedHolder, with an inert swarm: replication is piped by
179 // hand, so this test is about the hold, not the DHT.
180 const swarm = { connections: [], on () {}, join () {}, async leave () {} }
181 const holder = new FeedHolder({ storage: tmp(), swarm })
182 await holder.ready()
183 const held = await holder.hold(owner.key)
184 const unpipeBridge = pipe(owner, held.feed)
185
186 t.teardown(async () => {
187 unpipeStranger(); unpipeBridge()
188 await Promise.all([owner.close(), stranger.close(), ownerOnStranger.close()])
189 await holder.close()
190 })
191
192 const settles = (p, ms) => Promise.race([p.then(() => true), new Promise((r) => setTimeout(() => r(false), ms))])
193 const seen = await until(async () => {
194 if (!(await settles(held.feed.update(), 2000))) return null
195 return (await held.feed.pointers('post')).some((p) => p.name === 'after-knock') || null
196 }, { timeout: 15000 })
197
198 t.ok(seen, 'the bridge sees what the owner wrote after the knock')
199 t.absent(held.feed.base.writable && (await held.feed.devices()).includes(held.feed.deviceKey),
200 '⛔ and holding still grants the bridge nothing on the roster')
201})