OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / e2019fa

0.0.42: the timer was standing in for an event I had not looked for
You asked whether timers can express absence. They can — the standing rule is explicit that "a stalled-but-open conn emits ZERO events; absence is only observable by a clock", and that events are the latency FLOOR while timers are the staleness CEILING. But what 0.0.41's timer was covering is NOT absence. "Records whose bytes I do not hold" is positive, observable state, computable at any instant from pointers() minus what is on disk. Re-checking it on a clock is POLLING FOR DATA, which is the half of the rule that is absolute. Two events change that state and I had hooked only one: - a new pointer arrives → writer core 'append' (0.0.41) - a holder of the bytes appears → swarm 'connection' (missing) The second is what the timer was standing in for. A record is unresolved because nobody holding it is online; their return is an event. And the genuine stall already has a clock at the right layer — ensureLocal's 5s fetch timeout. So: onPeer() on swarm 'connection', and the periodic sweep is now EVENT-INFORMED and 5 minutes rather than 60 seconds — it skips itself entirely if an event already swept inside the window. This is the bridge watchdog's shape, where an append inside the refresh window SKIPS the poll. ★ AND IT COUNTS PER TRIGGER. `stats()` reports append / connection / stale / boot. An unconditional timer makes a dead hook look like a working feature — which is exactly how 0.0.40 would have passed, and nearly did: ~50s to mirror was the timer's signature, not a 2s debounce, and I was about to write it up as a pass. `stale` climbing while append and connection stay at zero is now a fact someone can read instead of infer from timing. favela-peer 336/336 tests, 800/800 asserts (was 334/795) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e2019fa612ab969021573b9f7e74d494c33a1cb3
by plankatron <fleeky@gmail.com> · 2026-09-08T18:44:11.000Z

Changed files (5) +116 -27

.pgh-lock.json +23 -23
……@@ -1,9 +1,9 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.40",
3 "version": "0.0.41",
44 "runtime": "bare",
5 "rootTreeHash": "8a28c6cd53e3085fcb2a09894d5c6507c15fdc81a38459939b8823a2f725c9a2",
6 "publishedAt": "2026-09-08T18:20:39.804Z",
5 "rootTreeHash": "5e5291250d721374a84f3649ebfe79da2535bd06c0edd4a7a4f2c8733e9daa60",
6 "publishedAt": "2026-09-08T18:37:47.228Z",
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:20:39.817Z"
3020 "acceptedAt": "2026-09-08T18:37:47.243Z"
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:20:39.817Z"
3070 "acceptedAt": "2026-09-08T18:37:47.243Z"
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:20:39.817Z"
3085 "acceptedAt": "2026-09-08T18:37:47.243Z"
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:20:39.817Z"
3110 "acceptedAt": "2026-09-08T18:37:47.243Z"
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:20:39.817Z"
3145 "acceptedAt": "2026-09-08T18:37:47.243Z"
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:20:39.817Z"
3160 "acceptedAt": "2026-09-08T18:37:47.243Z"
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:20:39.817Z"
3175 "acceptedAt": "2026-09-08T18:37:47.243Z"
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:20:39.817Z"
3190 "acceptedAt": "2026-09-08T18:37:47.243Z"
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:20:39.817Z"
3205 "acceptedAt": "2026-09-08T18:37:47.243Z"
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:20:39.817Z"
3235 "acceptedAt": "2026-09-08T18:37:47.243Z"
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:20:39.817Z"
3250 "acceptedAt": "2026-09-08T18:37:47.243Z"
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:20:39.817Z"
3280 "acceptedAt": "2026-09-08T18:37:47.243Z"
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:20:39.817Z"
3310 "acceptedAt": "2026-09-08T18:37:47.243Z"
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:20:39.817Z"
3325 "acceptedAt": "2026-09-08T18:37:47.243Z"
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:20:39.817Z"
3340 "acceptedAt": "2026-09-08T18:37:47.243Z"
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:20:39.817Z"
3370 "acceptedAt": "2026-09-08T18:37:47.243Z"
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:20:39.817Z"
3390 "acceptedAt": "2026-09-08T18:37:47.243Z"
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:20:39.817Z"
3415 "acceptedAt": "2026-09-08T18:37:47.243Z"
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:20:39.817Z"
3430 "acceptedAt": "2026-09-08T18:37:47.243Z"
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:20:39.817Z"
3445 "acceptedAt": "2026-09-08T18:37:47.243Z"
34463446 }
34473447 ]
34483448}
lib/mirror.js +31 -3
……@@ -40,6 +40,12 @@ export function createMirror ({ feeds, concurrency = DEFAULT_CONCURRENCY, deboun
4040 let running = false
4141 let timer = null
4242 let interval = null
43 let lastPassAt = 0
44 // ⛔ Counted per TRIGGER so a dead hook is OBSERVABLE. 0.0.40's watcher never fired and an
45 // unconditional timer would have hidden that — the feature would have looked like it worked.
46 // If `append` and `connection` stay at zero while `stale` climbs, the events are dead, and that
47 // is a fact anyone can read rather than infer from how long something took.
48 const fired = { append: 0, connection: 0, stale: 0, boot: 0 }
4349 const watched = []
4450
4551 async function mirrorFeed (key, feed) {
……@@ -113,6 +119,7 @@ export function createMirror ({ feeds, concurrency = DEFAULT_CONCURRENCY, deboun
113119 }
114120 } finally {
115121 running = false
122 lastPassAt = Date.now()
116123 }
117124 return { fetched, failed }
118125 },
……@@ -127,8 +134,9 @@ export function createMirror ({ feeds, concurrency = DEFAULT_CONCURRENCY, deboun
127134 * log that arrives thirty seconds later. Records must be fetched when the log ADVANCES, which
128135 * is the only moment we know there is something new to fetch.
129136 */
130 schedule () {
137 schedule (why = 'append') {
131138 if (stopped || timer) return
139 fired[why] = (fired[why] || 0) + 1
132140 timer = setTimeout(() => {
133141 timer = null
134142 this.pass().catch(() => {})
……@@ -173,6 +181,15 @@ export function createMirror ({ feeds, concurrency = DEFAULT_CONCURRENCY, deboun
173181 }
174182 },
175183
184 /**
185 * A peer connected — retry what we could not fetch before.
186 *
187 * ⛔ THIS IS THE EVENT THE TIMER WAS STANDING IN FOR. A record stays unresolved because nobody
188 * holding its bytes is online. Their coming back is a swarm 'connection', not an absence, and
189 * re-checking it on a clock was polling for data — the one thing the rule is absolute about.
190 */
191 onPeer () { this.schedule('connection') },
192
176193 /**
177194 * A slow sweep, so a missed signal cannot strand a feed for ever.
178195 *
……@@ -181,12 +198,23 @@ export function createMirror ({ feeds, concurrency = DEFAULT_CONCURRENCY, deboun
181198 * and touches the network only when something is genuinely missing. Given that 0.0.40 shipped a
182199 * trigger that silently never fired, a floor that cannot be wrong is worth its cost.
183200 */
184 every (ms = 60_000) {
201 every (ms = 300_000) {
185202 if (stopped || interval) return
186 interval = setInterval(() => { this.pass().catch(() => {}) }, ms)
203 interval = setInterval(() => {
204 // ⛔ EVENT-INFORMED: skipped entirely if an event already swept inside the window. Events
205 // are the latency floor; this is only the staleness ceiling, and it must not run in
206 // parallel with them or it masks a dead hook — which is exactly how 0.0.40 would have
207 // passed. Matches the bridge's watchdog: an append inside the window SKIPS the poll.
208 if (Date.now() - lastPassAt < ms) return
209 fired.stale++
210 this.pass().catch(() => {})
211 }, ms)
187212 if (interval && interval.unref) interval.unref()
188213 },
189214
215 /** Which trigger has actually been firing. `stale` climbing alone means the events are dead. */
216 stats () { return { ...fired, lastPassAt } },
217
190218 stop () {
191219 stopped = true
192220 if (timer) { clearTimeout(timer); timer = null }
lib/node.js +11 -0
……@@ -251,6 +251,16 @@ export class FavelaNode {
251251 // an interrupt, so it never fired at all — and nothing said so. A slow local sweep cannot be
252252 // wrong in that way: it reads a bee and stats files, and touches the network only when a record
253253 // is genuinely missing.
254 // ⛔ A PEER ARRIVING IS AN EVENT. A record is unresolved because nobody holding its bytes is
255 // online; their return is a swarm 'connection', not an absence, so it must not be discovered by
256 // a clock. This is the trigger the timer was standing in for.
257 if (this.swarm && typeof this.swarm.on === 'function') {
258 this._onMirrorPeer = () => this._mirror.onPeer()
259 this.swarm.on('connection', this._onMirrorPeer)
260 }
261 // ⚠ And a staleness CEILING, event-informed: it skips itself entirely when events are alive, so
262 // it cannot mask a dead hook the way 0.0.40's unconditional sweep would have. Events are the
263 // latency floor; this only catches what emits nothing at all.
254264 this._mirror.every()
255265
256266 return this
……@@ -735,6 +745,7 @@ export class FavelaNode {
735745 // Stop the mirror before anything it touches is torn down — a pass mid-fetch holding a
736746 // corestore session while the store closes throws into nobody's hands.
737747 if (this._mirror) this._mirror.stop()
748 if (this._onMirrorPeer && this.swarm) { try { this.swarm.off('connection', this._onMirrorPeer) } catch {} }
738749 // ⚠ Settle in-flight peeks FIRST. A background open still holding a corestore session while
739750 // this tears the store down throws "RocksDB session is closed" into nobody's hands — the open
740751 // has no caller left to reject to. In production these settle promptly: `hold()` awaits a feed
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.41",
3 "version": "0.0.42",
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 +50 -0
……@@ -256,3 +256,53 @@ test('★ the periodic sweep is a floor under a missed signal', async (t) => {
256256 t.ok(seen >= 2, 'it kept sweeping with no event at all (' + seen + ')')
257257 t.is(passes, seen, 'and stop() ended it')
258258})
259
260// ⛔ A PEER COMING BACK IS AN EVENT, NOT AN ABSENCE. A record stays unresolved because nobody
261// holding its bytes is online; their return is a swarm 'connection'. Re-checking that on a clock
262// was polling for data — the part of the rule that IS absolute.
263test('★★ a peer connecting retries what could not be fetched', async (t) => {
264 let peerHere = false
265 const fetched = []
266 const feed = {
267 async update () {},
268 async pointers () { return [{ key: 'post/a', collection: 'post', name: 'a', pointer: { $oid: 'o' } }] },
269 store: {
270 async get () { return null },
271 async ensureLocal (_c, n) { if (!peerHere) return null; fetched.push(n); return { body: n } }
272 }
273 }
274 const m = createMirror({ feeds: () => [{ key: 'f1', feed }], debounceMs: 10, log: () => {} })
275
276 await m.pass()
277 t.is(fetched.length, 0, 'nobody has it yet')
278
279 peerHere = true
280 m.onPeer()
281 await new Promise((r) => setTimeout(r, 60))
282
283 t.alike(fetched, ['a'], 'fetched when the holder came back')
284 t.is(m.stats().connection, 1, 'and the connection trigger is what did it')
285})
286
287// ⛔ THE PROPERTY THAT WOULD HAVE CAUGHT 0.0.40. An unconditional timer makes a dead event hook
288// look like a working feature. Event-informed means the ceiling skips itself when events are alive
289// — so `stale` climbing while `append` and `connection` stay at zero is a READABLE fact.
290test('★★ the staleness ceiling skips itself when events are alive', async (t) => {
291 const feed = {
292 async update () {},
293 async pointers () { return [] },
294 store: { async get () { return null }, async ensureLocal () { return null } }
295 }
296 const m = createMirror({ feeds: () => [{ key: 'f1', feed }], debounceMs: 5, log: () => {} })
297
298 m.every(40)
299 const base = fakeBase()
300 m.watch(base)
301
302 // Keep events flowing faster than the ceiling.
303 for (let i = 0; i < 5; i++) { base.emit(); await new Promise((r) => setTimeout(r, 20)) }
304 m.stop()
305
306 t.is(m.stats().stale, 0, 'the timer never needed to fire')
307 t.ok(m.stats().append > 0, 'because the event was doing the work (' + m.stats().append + ')')
308})