OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / 614410a

0.0.41: trigger on writer-core append, with a periodic floor
0.0.40 did not work and was published anyway. Its watch() subscribed to autobee's 'update' — an event emitted in exactly one place, inside _onError, when an interrupt is thrown. autobee's whole surface is interrupt / error / rotate-local-writer / move-to; there is NO view-advanced event. I found the emit by grep, took the first hit, and never read the enclosing function. The end-to-end run caught it: a marker posted on ashatron was still absent from tabertron after 120 seconds. updated() is not the answer either — it resolves immediately unless an apply is draining. It is a settle, not a signal, and a loop on it would spin. Three changes, and the third exists because of the first. 1. The trigger is now `w.core.on('append')` over the autobee's writer cores. A hypercore emits append when blocks land, including replicated ones, which is the earliest honest signal that someone else wrote something. 2. A sweep now calls feed.update() BEFORE pointers(). The view only advances when apply runs, so replicated blocks can sit in the writer cores with pointers() still reporting the old set — a sweep without this would keep finding nothing while the log had grown, indistinguishable from the bug it replaces. This makes the pass correct regardless of what triggered it. 3. A periodic sweep, default 60s, as a floor. ⚠ A timer on purpose: the rule here is not to POLL THE NETWORK for what an event should tell you, and this reads a local bee and stats local files, touching the network only when a record is actually missing. Writers admitted after boot are not hooked until the next restart, and 0.0.40 shipped a trigger that silently never fired — a floor that cannot fail that way is worth its cost. favela-peer 334/334 tests, 795/795 asserts (was 332/792) ⚠ STILL UNPROVEN END TO END. Two of the three mechanisms here exist because the suite passed while the feature did not work. It is not fixed until a second machine holds and serves real bytes with the author switched off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
614410a1e59afe85ca9641a9fc74bbec132acb40
by plankatron <fleeky@gmail.com> · 2026-09-08T18:35:46.000Z

Changed files (5) +139 -46

.pgh-lock.json +23 -23
……@@ -1,9 +1,9 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.39",
3 "version": "0.0.40",
44 "runtime": "bare",
5 "rootTreeHash": "04e36fead45d65dcfec11d3254cf361cc274c79741d8fc871300d463128ac69d",
6 "publishedAt": "2026-09-08T18:13:24.596Z",
5 "rootTreeHash": "8a28c6cd53e3085fcb2a09894d5c6507c15fdc81a38459939b8823a2f725c9a2",
6 "publishedAt": "2026-09-08T18:20:39.804Z",
77 "deps": [
88 {
99 "name": "@hyperswarm/secret-stream",
……@@ -3017,7 +3017,7 @@
30173017 }
30183018 ],
30193019 "reason": "version-splits only: dry-run reports 0 code-differs",
3020 "acceptedAt": "2026-09-08T18:13:24.612Z"
3020 "acceptedAt": "2026-09-08T18:20:39.817Z"
30213021 },
30223022 {
30233023 "type": "VERSION_CONFLICT",
……@@ -3067,7 +3067,7 @@
30673067 }
30683068 ],
30693069 "reason": "version-splits only: dry-run reports 0 code-differs",
3070 "acceptedAt": "2026-09-08T18:13:24.612Z"
3070 "acceptedAt": "2026-09-08T18:20:39.817Z"
30713071 },
30723072 {
30733073 "type": "VERSION_CONFLICT",
……@@ -3082,7 +3082,7 @@
30823082 }
30833083 ],
30843084 "reason": "version-splits only: dry-run reports 0 code-differs",
3085 "acceptedAt": "2026-09-08T18:13:24.612Z"
3085 "acceptedAt": "2026-09-08T18:20:39.817Z"
30863086 },
30873087 {
30883088 "type": "VERSION_CONFLICT",
……@@ -3107,7 +3107,7 @@
31073107 }
31083108 ],
31093109 "reason": "version-splits only: dry-run reports 0 code-differs",
3110 "acceptedAt": "2026-09-08T18:13:24.612Z"
3110 "acceptedAt": "2026-09-08T18:20:39.817Z"
31113111 },
31123112 {
31133113 "type": "VERSION_CONFLICT",
……@@ -3142,7 +3142,7 @@
31423142 }
31433143 ],
31443144 "reason": "version-splits only: dry-run reports 0 code-differs",
3145 "acceptedAt": "2026-09-08T18:13:24.612Z"
3145 "acceptedAt": "2026-09-08T18:20:39.817Z"
31463146 },
31473147 {
31483148 "type": "VERSION_CONFLICT",
……@@ -3157,7 +3157,7 @@
31573157 }
31583158 ],
31593159 "reason": "version-splits only: dry-run reports 0 code-differs",
3160 "acceptedAt": "2026-09-08T18:13:24.612Z"
3160 "acceptedAt": "2026-09-08T18:20:39.817Z"
31613161 },
31623162 {
31633163 "type": "VERSION_CONFLICT",
……@@ -3172,7 +3172,7 @@
31723172 }
31733173 ],
31743174 "reason": "version-splits only: dry-run reports 0 code-differs",
3175 "acceptedAt": "2026-09-08T18:13:24.612Z"
3175 "acceptedAt": "2026-09-08T18:20:39.817Z"
31763176 },
31773177 {
31783178 "type": "VERSION_CONFLICT",
……@@ -3187,7 +3187,7 @@
31873187 }
31883188 ],
31893189 "reason": "version-splits only: dry-run reports 0 code-differs",
3190 "acceptedAt": "2026-09-08T18:13:24.612Z"
3190 "acceptedAt": "2026-09-08T18:20:39.817Z"
31913191 },
31923192 {
31933193 "type": "VERSION_CONFLICT",
……@@ -3202,7 +3202,7 @@
32023202 }
32033203 ],
32043204 "reason": "version-splits only: dry-run reports 0 code-differs",
3205 "acceptedAt": "2026-09-08T18:13:24.612Z"
3205 "acceptedAt": "2026-09-08T18:20:39.817Z"
32063206 },
32073207 {
32083208 "type": "VERSION_CONFLICT",
……@@ -3232,7 +3232,7 @@
32323232 }
32333233 ],
32343234 "reason": "version-splits only: dry-run reports 0 code-differs",
3235 "acceptedAt": "2026-09-08T18:13:24.613Z"
3235 "acceptedAt": "2026-09-08T18:20:39.817Z"
32363236 },
32373237 {
32383238 "type": "VERSION_CONFLICT",
……@@ -3247,7 +3247,7 @@
32473247 }
32483248 ],
32493249 "reason": "version-splits only: dry-run reports 0 code-differs",
3250 "acceptedAt": "2026-09-08T18:13:24.613Z"
3250 "acceptedAt": "2026-09-08T18:20:39.817Z"
32513251 },
32523252 {
32533253 "type": "VERSION_CONFLICT",
……@@ -3277,7 +3277,7 @@
32773277 }
32783278 ],
32793279 "reason": "version-splits only: dry-run reports 0 code-differs",
3280 "acceptedAt": "2026-09-08T18:13:24.613Z"
3280 "acceptedAt": "2026-09-08T18:20:39.817Z"
32813281 },
32823282 {
32833283 "type": "VERSION_CONFLICT",
……@@ -3307,7 +3307,7 @@
33073307 }
33083308 ],
33093309 "reason": "version-splits only: dry-run reports 0 code-differs",
3310 "acceptedAt": "2026-09-08T18:13:24.613Z"
3310 "acceptedAt": "2026-09-08T18:20:39.817Z"
33113311 },
33123312 {
33133313 "type": "VERSION_CONFLICT",
……@@ -3322,7 +3322,7 @@
33223322 }
33233323 ],
33243324 "reason": "version-splits only: dry-run reports 0 code-differs",
3325 "acceptedAt": "2026-09-08T18:13:24.613Z"
3325 "acceptedAt": "2026-09-08T18:20:39.817Z"
33263326 },
33273327 {
33283328 "type": "VERSION_CONFLICT",
……@@ -3337,7 +3337,7 @@
33373337 }
33383338 ],
33393339 "reason": "version-splits only: dry-run reports 0 code-differs",
3340 "acceptedAt": "2026-09-08T18:13:24.613Z"
3340 "acceptedAt": "2026-09-08T18:20:39.817Z"
33413341 },
33423342 {
33433343 "type": "VERSION_CONFLICT",
……@@ -3367,7 +3367,7 @@
33673367 }
33683368 ],
33693369 "reason": "version-splits only: dry-run reports 0 code-differs",
3370 "acceptedAt": "2026-09-08T18:13:24.613Z"
3370 "acceptedAt": "2026-09-08T18:20:39.817Z"
33713371 },
33723372 {
33733373 "type": "VERSION_CONFLICT",
……@@ -3387,7 +3387,7 @@
33873387 }
33883388 ],
33893389 "reason": "version-splits only: dry-run reports 0 code-differs",
3390 "acceptedAt": "2026-09-08T18:13:24.613Z"
3390 "acceptedAt": "2026-09-08T18:20:39.817Z"
33913391 },
33923392 {
33933393 "type": "VERSION_CONFLICT",
……@@ -3412,7 +3412,7 @@
34123412 }
34133413 ],
34143414 "reason": "version-splits only: dry-run reports 0 code-differs",
3415 "acceptedAt": "2026-09-08T18:13:24.613Z"
3415 "acceptedAt": "2026-09-08T18:20:39.817Z"
34163416 },
34173417 {
34183418 "type": "VERSION_CONFLICT",
……@@ -3427,7 +3427,7 @@
34273427 }
34283428 ],
34293429 "reason": "version-splits only: dry-run reports 0 code-differs",
3430 "acceptedAt": "2026-09-08T18:13:24.613Z"
3430 "acceptedAt": "2026-09-08T18:20:39.817Z"
34313431 },
34323432 {
34333433 "type": "VERSION_CONFLICT",
……@@ -3442,7 +3442,7 @@
34423442 }
34433443 ],
34443444 "reason": "version-splits only: dry-run reports 0 code-differs",
3445 "acceptedAt": "2026-09-08T18:13:24.613Z"
3445 "acceptedAt": "2026-09-08T18:20:39.817Z"
34463446 }
34473447 ]
34483448}
lib/mirror.js +52 -6
……@@ -39,6 +39,7 @@ export function createMirror ({ feeds, concurrency = DEFAULT_CONCURRENCY, deboun
3939 let stopped = false
4040 let running = false
4141 let timer = null
42 let interval = null
4243 const watched = []
4344
4445 async function mirrorFeed (key, feed) {
……@@ -47,6 +48,12 @@ export function createMirror ({ feeds, concurrency = DEFAULT_CONCURRENCY, deboun
4748 // this pass be bounded at all.
4849 let rows
4950 try {
51 // ⛔ REFRESH THE VIEW FIRST. `pointers()` reads the linearised view, and the view only
52 // advances when apply runs. Replicated blocks can be sitting in the writer cores with the
53 // view still behind them — so a sweep that skipped this would keep reporting "nothing new"
54 // while the log had in fact grown. This is what makes the pass correct no matter what
55 // triggered it.
56 if (typeof feed.update === 'function') await feed.update()
5057 rows = await feed.pointers()
5158 } catch (err) {
5259 // A feed whose view is not ready yet is not a failure of the pass. It will be there next time.
……@@ -135,17 +142,56 @@ export function createMirror ({ feeds, concurrency = DEFAULT_CONCURRENCY, deboun
135142 * rather than `pass` directly.
136143 */
137144 watch (base) {
138 if (!base || typeof base.on !== 'function') return () => {}
139 const onUpdate = () => this.schedule()
140 base.on('update', onUpdate)
141 watched.push([base, onUpdate])
142 return () => { try { base.off('update', onUpdate) } catch {} }
145 if (!base) return () => {}
146 const onAppend = () => this.schedule()
147
148 // ⛔ THE WRITER CORES, not the autobee. autobee emits no "view advanced" event: its whole
149 // surface is interrupt / error / rotate-local-writer / move-to, and the one `emit('update')`
150 // fires inside _onError on an INTERRUPT. 0.0.40 subscribed to it and therefore never fired —
151 // caught only by an end-to-end run, because a fake emitter emits whatever you tell it to.
152 //
153 // A hypercore emits 'append' when blocks land, INCLUDING replicated ones, which is the
154 // earliest honest signal that someone else wrote something.
155 //
156 // ⚠ Writers admitted later are not hooked until the next boot. The periodic sweep below is
157 // what stops that being a hole.
158 let hooked = 0
159 try {
160 for (const w of (base.writers || [])) {
161 if (!w || !w.core || typeof w.core.on !== 'function') continue
162 w.core.on('append', onAppend)
163 watched.push([w.core, onAppend])
164 hooked++
165 }
166 } catch (err) {
167 log({ msg: 'mirror: could not watch writer cores', error: err.message })
168 }
169 if (!hooked) log({ msg: 'mirror: no writer cores to watch — relying on the periodic sweep' })
170 return () => {
171 for (const [c, fn] of watched) { try { c.off('append', fn) } catch {} }
172 watched.length = 0
173 }
174 },
175
176 /**
177 * A slow sweep, so a missed signal cannot strand a feed for ever.
178 *
179 * ⚠ Yes, a timer — and deliberately. The rule this fleet keeps re-learning is not to POLL THE
180 * NETWORK for something an event should tell you; this polls a local bee and stats local files,
181 * and touches the network only when something is genuinely missing. Given that 0.0.40 shipped a
182 * trigger that silently never fired, a floor that cannot be wrong is worth its cost.
183 */
184 every (ms = 60_000) {
185 if (stopped || interval) return
186 interval = setInterval(() => { this.pass().catch(() => {}) }, ms)
187 if (interval && interval.unref) interval.unref()
143188 },
144189
145190 stop () {
146191 stopped = true
147192 if (timer) { clearTimeout(timer); timer = null }
148 for (const [base, fn] of watched) { try { base.off('update', fn) } catch {} }
193 if (interval) { clearInterval(interval); interval = null }
194 for (const [c, fn] of watched) { try { c.off('append', fn) } catch {} }
149195 watched.length = 0
150196 }
151197 }
lib/node.js +5 -0
……@@ -247,6 +247,11 @@ export class FavelaNode {
247247 // feed's log has usually not replicated yet, so the real work is done by the 'update' watcher
248248 // attached in hold().
249249 this._mirror.pass().catch(() => {})
250 // ⛔ AND A FLOOR UNDER IT. 0.0.40's trigger subscribed to an autobee event that only fires on
251 // an interrupt, so it never fired at all — and nothing said so. A slow local sweep cannot be
252 // wrong in that way: it reads a bee and stats files, and touches the network only when a record
253 // is genuinely missing.
254 this._mirror.every()
250255
251256 return this
252257 }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.40",
3 "version": "0.0.41",
44 "description": "The favela daemon — feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.",
55 "type": "module",
66 "scripts": {
test/mirror.test.js +58 -16
……@@ -39,6 +39,17 @@ function fakeFeed (names, { local = [], fail = [], onFetch = () => {} } = {}) {
3939 }
4040}
4141
42// An autobee's writer cores. `w.core.on('append')` is the real signal that blocks landed —
43// including replicated ones. autobee itself emits no view-advanced event.
44function fakeBase () {
45 const h = []
46 const core = {
47 on: (_e, fn) => h.push(fn),
48 off: (_e, fn) => { const i = h.indexOf(fn); if (i >= 0) h.splice(i, 1) }
49 }
50 return { writers: [{ core }], emit: () => h.slice().forEach((fn) => fn()), listeners: () => h.length }
51}
52
4253test('★★ it fetches records nobody asked to read', async (t) => {
4354 const fetched = []
4455 const feed = fakeFeed(['a', 'b', 'c'], { onFetch: (n) => fetched.push(n) })
……@@ -154,13 +165,8 @@ test('★★ a log that advances AFTER the boot pass still gets mirrored', async
154165 }
155166 }
156167
157 // Minimal emitter — autobee emits 'update' whenever the view advances.
158 const handlers = []
159 const base = {
160 on: (_e, fn) => handlers.push(fn),
161 off: (_e, fn) => { const i = handlers.indexOf(fn); if (i >= 0) handlers.splice(i, 1) },
162 emit: () => handlers.slice().forEach((fn) => fn())
163 }
168 // ⛔ A WRITER CORE, not the autobee. autobee emits no view-advanced event — see mirror.js.
169 const base = fakeBase()
164170
165171 const m = createMirror({ feeds: () => [{ key: 'f1', feed }], debounceMs: 10, log: () => {} })
166172 m.watch(base)
……@@ -181,8 +187,7 @@ test('★ a burst of updates coalesces into one pass', async (t) => {
181187 async pointers () { passes++; return [] },
182188 store: { async get () { return null }, async ensureLocal () { return null } }
183189 }
184 const handlers = []
185 const base = { on: (_e, fn) => handlers.push(fn), off: () => {}, emit: () => handlers.forEach((fn) => fn()) }
190 const base = fakeBase()
186191
187192 const m = createMirror({ feeds: () => [{ key: 'f1', feed }], debounceMs: 20, log: () => {} })
188193 m.watch(base)
……@@ -198,12 +203,7 @@ test('★ stop() detaches the watcher', async (t) => {
198203 async pointers () { passes++; return [] },
199204 store: { async get () { return null }, async ensureLocal () { return null } }
200205 }
201 const handlers = []
202 const base = {
203 on: (_e, fn) => handlers.push(fn),
204 off: (_e, fn) => { const i = handlers.indexOf(fn); if (i >= 0) handlers.splice(i, 1) },
205 emit: () => handlers.slice().forEach((fn) => fn())
206 }
206 const base = fakeBase()
207207
208208 const m = createMirror({ feeds: () => [{ key: 'f1', feed }], debounceMs: 10, log: () => {} })
209209 m.watch(base)
……@@ -212,5 +212,47 @@ test('★ stop() detaches the watcher', async (t) => {
212212 await new Promise((r) => setTimeout(r, 40))
213213
214214 t.is(passes, 0, 'nothing sweeps after stop')
215 t.is(handlers.length, 0, 'and the listener is gone')
215 t.is(base.listeners(), 0, 'and the listener is gone')
216})
217
218// ⛔ The view only advances when apply runs, so replicated blocks can be sitting in the writer
219// cores with `pointers()` still reporting the old set. A sweep that skipped the refresh would keep
220// finding "nothing new" while the log had grown — which is indistinguishable from the 0.0.40 bug
221// and would have been just as invisible.
222test('★★ a sweep refreshes the view before reading pointers', async (t) => {
223 const order = []
224 let names = []
225 const feed = {
226 async update () { order.push('update'); names = ['fresh'] },
227 async pointers () {
228 order.push('pointers')
229 return names.map((n) => ({ key: 'post/' + n, collection: 'post', name: n, pointer: { $oid: 'o' } }))
230 },
231 store: { async get () { return null }, async ensureLocal (_c, n) { order.push('fetch:' + n); return { body: n } } }
232 }
233
234 await createMirror({ feeds: () => [{ key: 'f1', feed }], log: () => {} }).pass()
235
236 t.alike(order, ['update', 'pointers', 'fetch:fresh'], 'refresh, then read, then fetch')
237})
238
239// ⚠ A timer on purpose. 0.0.40 shipped a trigger that silently never fired; a floor that cannot be
240// wrong is worth its cost. It polls a local bee and stats local files — not the network.
241test('★ the periodic sweep is a floor under a missed signal', async (t) => {
242 let passes = 0
243 const feed = {
244 async update () {},
245 async pointers () { passes++; return [] },
246 store: { async get () { return null }, async ensureLocal () { return null } }
247 }
248 const m = createMirror({ feeds: () => [{ key: 'f1', feed }], log: () => {} })
249
250 m.every(20)
251 await new Promise((r) => setTimeout(r, 90))
252 m.stop()
253 const seen = passes
254 await new Promise((r) => setTimeout(r, 50))
255
256 t.ok(seen >= 2, 'it kept sweeping with no event at all (' + seen + ')')
257 t.is(passes, seen, 'and stop() ended it')
216258})