| … | … | @@ -1,9 +1,9 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-peer", |
| 3 | "version": "0.0.40", | |
| 3 | "version": "0.0.41", | |
| 4 | 4 | "runtime": "bare", |
| 5 | "rootTreeHash": "8a28c6cd53e3085fcb2a09894d5c6507c15fdc81a38459939b8823a2f725c9a2", | |
| 6 | "publishedAt": "2026-09-08T18:20:39.804Z", | |
| 5 | "rootTreeHash": "5e5291250d721374a84f3649ebfe79da2535bd06c0edd4a7a4f2c8733e9daa60", | |
| 6 | "publishedAt": "2026-09-08T18:37:47.228Z", | |
| 7 | 7 | "deps": [ |
| 8 | 8 | { |
| 9 | 9 | "name": "@hyperswarm/secret-stream", |
| … | … | @@ -3017,7 +3017,7 @@ |
| 3017 | 3017 | } |
| 3018 | 3018 | ], |
| 3019 | 3019 | "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" | |
| 3021 | 3021 | }, |
| 3022 | 3022 | { |
| 3023 | 3023 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3067,7 +3067,7 @@ |
| 3067 | 3067 | } |
| 3068 | 3068 | ], |
| 3069 | 3069 | "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" | |
| 3071 | 3071 | }, |
| 3072 | 3072 | { |
| 3073 | 3073 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3082,7 +3082,7 @@ |
| 3082 | 3082 | } |
| 3083 | 3083 | ], |
| 3084 | 3084 | "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" | |
| 3086 | 3086 | }, |
| 3087 | 3087 | { |
| 3088 | 3088 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3107,7 +3107,7 @@ |
| 3107 | 3107 | } |
| 3108 | 3108 | ], |
| 3109 | 3109 | "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" | |
| 3111 | 3111 | }, |
| 3112 | 3112 | { |
| 3113 | 3113 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3142,7 +3142,7 @@ |
| 3142 | 3142 | } |
| 3143 | 3143 | ], |
| 3144 | 3144 | "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" | |
| 3146 | 3146 | }, |
| 3147 | 3147 | { |
| 3148 | 3148 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3157,7 +3157,7 @@ |
| 3157 | 3157 | } |
| 3158 | 3158 | ], |
| 3159 | 3159 | "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" | |
| 3161 | 3161 | }, |
| 3162 | 3162 | { |
| 3163 | 3163 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3172,7 +3172,7 @@ |
| 3172 | 3172 | } |
| 3173 | 3173 | ], |
| 3174 | 3174 | "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" | |
| 3176 | 3176 | }, |
| 3177 | 3177 | { |
| 3178 | 3178 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3187,7 +3187,7 @@ |
| 3187 | 3187 | } |
| 3188 | 3188 | ], |
| 3189 | 3189 | "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" | |
| 3191 | 3191 | }, |
| 3192 | 3192 | { |
| 3193 | 3193 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3202,7 +3202,7 @@ |
| 3202 | 3202 | } |
| 3203 | 3203 | ], |
| 3204 | 3204 | "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" | |
| 3206 | 3206 | }, |
| 3207 | 3207 | { |
| 3208 | 3208 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3232,7 +3232,7 @@ |
| 3232 | 3232 | } |
| 3233 | 3233 | ], |
| 3234 | 3234 | "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" | |
| 3236 | 3236 | }, |
| 3237 | 3237 | { |
| 3238 | 3238 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3247,7 +3247,7 @@ |
| 3247 | 3247 | } |
| 3248 | 3248 | ], |
| 3249 | 3249 | "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" | |
| 3251 | 3251 | }, |
| 3252 | 3252 | { |
| 3253 | 3253 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3277,7 +3277,7 @@ |
| 3277 | 3277 | } |
| 3278 | 3278 | ], |
| 3279 | 3279 | "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" | |
| 3281 | 3281 | }, |
| 3282 | 3282 | { |
| 3283 | 3283 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3307,7 +3307,7 @@ |
| 3307 | 3307 | } |
| 3308 | 3308 | ], |
| 3309 | 3309 | "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" | |
| 3311 | 3311 | }, |
| 3312 | 3312 | { |
| 3313 | 3313 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3322,7 +3322,7 @@ |
| 3322 | 3322 | } |
| 3323 | 3323 | ], |
| 3324 | 3324 | "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" | |
| 3326 | 3326 | }, |
| 3327 | 3327 | { |
| 3328 | 3328 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3337,7 +3337,7 @@ |
| 3337 | 3337 | } |
| 3338 | 3338 | ], |
| 3339 | 3339 | "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" | |
| 3341 | 3341 | }, |
| 3342 | 3342 | { |
| 3343 | 3343 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3367,7 +3367,7 @@ |
| 3367 | 3367 | } |
| 3368 | 3368 | ], |
| 3369 | 3369 | "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" | |
| 3371 | 3371 | }, |
| 3372 | 3372 | { |
| 3373 | 3373 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3387,7 +3387,7 @@ |
| 3387 | 3387 | } |
| 3388 | 3388 | ], |
| 3389 | 3389 | "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" | |
| 3391 | 3391 | }, |
| 3392 | 3392 | { |
| 3393 | 3393 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3412,7 +3412,7 @@ |
| 3412 | 3412 | } |
| 3413 | 3413 | ], |
| 3414 | 3414 | "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" | |
| 3416 | 3416 | }, |
| 3417 | 3417 | { |
| 3418 | 3418 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3427,7 +3427,7 @@ |
| 3427 | 3427 | } |
| 3428 | 3428 | ], |
| 3429 | 3429 | "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" | |
| 3431 | 3431 | }, |
| 3432 | 3432 | { |
| 3433 | 3433 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3442,7 +3442,7 @@ |
| 3442 | 3442 | } |
| 3443 | 3443 | ], |
| 3444 | 3444 | "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" | |
| 3446 | 3446 | } |
| 3447 | 3447 | ] |
| 3448 | 3448 | } |
| … | … | @@ -40,6 +40,12 @@ export function createMirror ({ feeds, concurrency = DEFAULT_CONCURRENCY, deboun |
| 40 | 40 | let running = false |
| 41 | 41 | let timer = null |
| 42 | 42 | 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 } | |
| 43 | 49 | const watched = [] |
| 44 | 50 | |
| 45 | 51 | async function mirrorFeed (key, feed) { |
| … | … | @@ -113,6 +119,7 @@ export function createMirror ({ feeds, concurrency = DEFAULT_CONCURRENCY, deboun |
| 113 | 119 | } |
| 114 | 120 | } finally { |
| 115 | 121 | running = false |
| 122 | lastPassAt = Date.now() | |
| 116 | 123 | } |
| 117 | 124 | return { fetched, failed } |
| 118 | 125 | }, |
| … | … | @@ -127,8 +134,9 @@ export function createMirror ({ feeds, concurrency = DEFAULT_CONCURRENCY, deboun |
| 127 | 134 | * log that arrives thirty seconds later. Records must be fetched when the log ADVANCES, which |
| 128 | 135 | * is the only moment we know there is something new to fetch. |
| 129 | 136 | */ |
| 130 | schedule () { | |
| 137 | schedule (why = 'append') { | |
| 131 | 138 | if (stopped || timer) return |
| 139 | fired[why] = (fired[why] || 0) + 1 | |
| 132 | 140 | timer = setTimeout(() => { |
| 133 | 141 | timer = null |
| 134 | 142 | this.pass().catch(() => {}) |
| … | … | @@ -173,6 +181,15 @@ export function createMirror ({ feeds, concurrency = DEFAULT_CONCURRENCY, deboun |
| 173 | 181 | } |
| 174 | 182 | }, |
| 175 | 183 | |
| 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 | ||
| 176 | 193 | /** |
| 177 | 194 | * A slow sweep, so a missed signal cannot strand a feed for ever. |
| 178 | 195 | * |
| … | … | @@ -181,12 +198,23 @@ export function createMirror ({ feeds, concurrency = DEFAULT_CONCURRENCY, deboun |
| 181 | 198 | * and touches the network only when something is genuinely missing. Given that 0.0.40 shipped a |
| 182 | 199 | * trigger that silently never fired, a floor that cannot be wrong is worth its cost. |
| 183 | 200 | */ |
| 184 | every (ms = 60_000) { | |
| 201 | every (ms = 300_000) { | |
| 185 | 202 | 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) | |
| 187 | 212 | if (interval && interval.unref) interval.unref() |
| 188 | 213 | }, |
| 189 | 214 | |
| 215 | /** Which trigger has actually been firing. `stale` climbing alone means the events are dead. */ | |
| 216 | stats () { return { ...fired, lastPassAt } }, | |
| 217 | ||
| 190 | 218 | stop () { |
| 191 | 219 | stopped = true |
| 192 | 220 | if (timer) { clearTimeout(timer); timer = null } |
| … | … | @@ -251,6 +251,16 @@ export class FavelaNode { |
| 251 | 251 | // an interrupt, so it never fired at all — and nothing said so. A slow local sweep cannot be |
| 252 | 252 | // wrong in that way: it reads a bee and stats files, and touches the network only when a record |
| 253 | 253 | // 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. | |
| 254 | 264 | this._mirror.every() |
| 255 | 265 | |
| 256 | 266 | return this |
| … | … | @@ -735,6 +745,7 @@ export class FavelaNode { |
| 735 | 745 | // Stop the mirror before anything it touches is torn down — a pass mid-fetch holding a |
| 736 | 746 | // corestore session while the store closes throws into nobody's hands. |
| 737 | 747 | if (this._mirror) this._mirror.stop() |
| 748 | if (this._onMirrorPeer && this.swarm) { try { this.swarm.off('connection', this._onMirrorPeer) } catch {} } | |
| 738 | 749 | // ⚠ Settle in-flight peeks FIRST. A background open still holding a corestore session while |
| 739 | 750 | // this tears the store down throws "RocksDB session is closed" into nobody's hands — the open |
| 740 | 751 | // has no caller left to reject to. In production these settle promptly: `hold()` awaits a feed |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-peer", |
| 3 | "version": "0.0.41", | |
| 3 | "version": "0.0.42", | |
| 4 | 4 | "description": "The favela daemon — feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "scripts": { |
| … | … | @@ -256,3 +256,53 @@ test('★ the periodic sweep is a floor under a missed signal', async (t) => { |
| 256 | 256 | t.ok(seen >= 2, 'it kept sweeping with no event at all (' + seen + ')') |
| 257 | 257 | t.is(passes, seen, 'and stop() ended it') |
| 258 | 258 | }) |
| 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. | |
| 263 | test('★★ 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. | |
| 290 | test('★★ 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 | }) |