OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / 1ce3751

0.0.40: mirror on log advance — the boot pass alone finds nothing
⛔ FOUND BY THE END-TO-END RUN, not by the suite. 0.0.39 swept once at boot. Measured on two machines: a record posted on ashatron was still absent from tabertron 2.5 minutes and one restart later, and appeared instantly the moment something READ it. The cause is not subtle once seen. At boot a followed feed's log has usually NOT replicated yet — the swarm connection is still forming — so the sweep runs, correctly finds nothing to do, and never runs again. `unresolved: 0` right after the restart is the proof: tabertron did not have the POINTER yet at the moment its only sweep happened. Every fake in the suite hands its pointers back immediately, so no test could express "the log arrives thirty seconds later". Three now do: a log that advances after the boot pass, a burst that must coalesce, and stop() detaching. mirror.watch(base) subscribes to autobee's 'update' and sweeps on a 2s debounce. Autobee emits on every view change, far more often than the log gains records, so debouncing is required rather than tidy. node.js constructs the mirror BEFORE holding feeds so hold() can attach a watcher to each as it opens, and stop() detaches them all. favela-peer 332/332 tests, 792/792 asserts (was 329/787) ⚠ STILL UNPROVEN END TO END. The suite now covers the case that broke, against fakes. It does not prove a second machine holds and serves real bytes after the author goes away. That is the next run, and 0.0.39 is why I will not report it as working until it has been done. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1ce3751af3e74fdc5489ccf68b7a88f99b984974
by plankatron <fleeky@gmail.com> · 2026-09-08T18:19:44.000Z

Changed files (5) +204 -70

.pgh-lock.json +67 -60
……@@ -1,9 +1,9 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.36",
3 "version": "0.0.39",
44 "runtime": "bare",
5 "rootTreeHash": "23b06cbe3a6df55b474321e938d032a04b6eea4ab383602bc2981871b178d47e",
6 "publishedAt": "2026-09-08T01:03:14.653Z",
5 "rootTreeHash": "04e36fead45d65dcfec11d3254cf361cc274c79741d8fc871300d463128ac69d",
6 "publishedAt": "2026-09-08T18:13:24.596Z",
77 "deps": [
88 {
99 "name": "@hyperswarm/secret-stream",
……@@ -1569,11 +1569,32 @@
15691569 "normHash": "f497f1d5687f9247f4e8466f8ac4040e8cf9836afb26fa3f45bd22b2fcdcf05f",
15701570 "workspace": false
15711571 },
1572 {
1573 "name": "favela-identity",
1574 "version": "0.0.2",
1575 "tree": "7b3f314cf92558edbc90295c573885276a6add925318998c829c03d335edc765",
1576 "normHash": "764cabc80c7ebc250bf01507b94d7e0960fa26958305cae299a326913d716d5f",
1577 "workspace": true
1578 },
1579 {
1580 "name": "hypercore-crypto",
1581 "version": "3.7.0",
1582 "tree": "a0738332691e3ac4fa89e49d92d6edfc420996b80dcf0e522c7557c5f53267d5",
1583 "normHash": "0ad646c6f4c2207474afbd26b9ed57c0bf8f81f701c262ef8299da78350dffcc",
1584 "workspace": false
1585 },
1586 {
1587 "name": "keet-identity-key",
1588 "version": "3.2.0",
1589 "tree": "f49a14a1e514c3b607ba0211e7d993e37a387ad60456831abaff3c5eae917114",
1590 "normHash": "f8caf0df07b1b182c79ea2f7efa13c272d862d17314ebeef801edd7452e07636",
1591 "workspace": false
1592 },
15721593 {
15731594 "name": "favela-markdown",
1574 "version": "0.1.4",
1575 "tree": "e303eceac5fcb441423d31d16fd57d096491c8133d7b3b09d5979817a831e39a",
1576 "normHash": "efb446f13654a3b5c5271ce6c07ea60db76309018e12a630f03e8829965df6c7",
1595 "version": "0.1.6",
1596 "tree": "97d64cb2674377910d3ec28f3f4246d7e3fbc1b9b06b8b9d1ef6360cc0765569",
1597 "normHash": "853f6fe2c24cea0c956653eaa7d5a13b7feca966cad5c9a6164c2dc1ff324881",
15771598 "workspace": true
15781599 },
15791600 {
……@@ -2081,13 +2102,6 @@
20812102 "normHash": "b546d891289bf9c96a41ec0f28d940ab755cbe9aebc8ec8fb7a8066130dd3fb1",
20822103 "workspace": true
20832104 },
2084 {
2085 "name": "hypercore-crypto",
2086 "version": "3.7.0",
2087 "tree": "a0738332691e3ac4fa89e49d92d6edfc420996b80dcf0e522c7557c5f53267d5",
2088 "normHash": "0ad646c6f4c2207474afbd26b9ed57c0bf8f81f701c262ef8299da78350dffcc",
2089 "workspace": false
2090 },
20912105 {
20922106 "name": "paparam",
20932107 "version": "1.13.0",
……@@ -2952,13 +2966,6 @@
29522966 "normHash": "2c833f38bcd7ee1f2208d8965be025d5f9cd7a117b121ee74133552f25294484",
29532967 "workspace": false
29542968 },
2955 {
2956 "name": "keet-identity-key",
2957 "version": "3.2.0",
2958 "tree": "f49a14a1e514c3b607ba0211e7d993e37a387ad60456831abaff3c5eae917114",
2959 "normHash": "f8caf0df07b1b182c79ea2f7efa13c272d862d17314ebeef801edd7452e07636",
2960 "workspace": false
2961 },
29622969 {
29632970 "name": "sodium-hmac",
29642971 "version": "2.1.0",
……@@ -3009,8 +3016,8 @@
30093016 "tree": "c5f18b3620514f54714aeb351f074adface216a335242e323e33d28d261376fe"
30103017 }
30113018 ],
3012 "reason": "0.0.36 reach",
3013 "acceptedAt": "2026-09-08T01:03:14.667Z"
3019 "reason": "version-splits only: dry-run reports 0 code-differs",
3020 "acceptedAt": "2026-09-08T18:13:24.612Z"
30143021 },
30153022 {
30163023 "type": "VERSION_CONFLICT",
……@@ -3059,8 +3066,8 @@
30593066 "tree": "6bb5233025d1c30e4f181e29b6ab5af549d8909c7070c7b4438b30f1a4e76ce2"
30603067 }
30613068 ],
3062 "reason": "0.0.36 reach",
3063 "acceptedAt": "2026-09-08T01:03:14.667Z"
3069 "reason": "version-splits only: dry-run reports 0 code-differs",
3070 "acceptedAt": "2026-09-08T18:13:24.612Z"
30643071 },
30653072 {
30663073 "type": "VERSION_CONFLICT",
……@@ -3074,8 +3081,8 @@
30743081 "tree": "58c0bf8698af19464d0ee8354a77ed1ac96d3a5fa18aa4e794686cfbbf080e5d"
30753082 }
30763083 ],
3077 "reason": "0.0.36 reach",
3078 "acceptedAt": "2026-09-08T01:03:14.667Z"
3084 "reason": "version-splits only: dry-run reports 0 code-differs",
3085 "acceptedAt": "2026-09-08T18:13:24.612Z"
30793086 },
30803087 {
30813088 "type": "VERSION_CONFLICT",
……@@ -3099,8 +3106,8 @@
30993106 "tree": "bdebba5a532ce4846b49f1e5ffad774becfc44182d0c3737f7e441ab42f4c43d"
31003107 }
31013108 ],
3102 "reason": "0.0.36 reach",
3103 "acceptedAt": "2026-09-08T01:03:14.667Z"
3109 "reason": "version-splits only: dry-run reports 0 code-differs",
3110 "acceptedAt": "2026-09-08T18:13:24.612Z"
31043111 },
31053112 {
31063113 "type": "VERSION_CONFLICT",
……@@ -3134,8 +3141,8 @@
31343141 "tree": "b799e6ba8726aa951b45f12cc7ecffe5f4e9ee1e8adf09a34d026d9d5658828a"
31353142 }
31363143 ],
3137 "reason": "0.0.36 reach",
3138 "acceptedAt": "2026-09-08T01:03:14.667Z"
3144 "reason": "version-splits only: dry-run reports 0 code-differs",
3145 "acceptedAt": "2026-09-08T18:13:24.612Z"
31393146 },
31403147 {
31413148 "type": "VERSION_CONFLICT",
……@@ -3149,8 +3156,8 @@
31493156 "tree": "e92100a4f821a638ac99c1407e4c7e784f8eb9661e6d9119ecbec4faae77cc7e"
31503157 }
31513158 ],
3152 "reason": "0.0.36 reach",
3153 "acceptedAt": "2026-09-08T01:03:14.667Z"
3159 "reason": "version-splits only: dry-run reports 0 code-differs",
3160 "acceptedAt": "2026-09-08T18:13:24.612Z"
31543161 },
31553162 {
31563163 "type": "VERSION_CONFLICT",
……@@ -3164,8 +3171,8 @@
31643171 "tree": "1f5de2daa08643a5cb8bce2153ca4d55d4504fb366f1f1ef80bca1700ec085f7"
31653172 }
31663173 ],
3167 "reason": "0.0.36 reach",
3168 "acceptedAt": "2026-09-08T01:03:14.667Z"
3174 "reason": "version-splits only: dry-run reports 0 code-differs",
3175 "acceptedAt": "2026-09-08T18:13:24.612Z"
31693176 },
31703177 {
31713178 "type": "VERSION_CONFLICT",
……@@ -3179,8 +3186,8 @@
31793186 "tree": "579bdc1eaabbca726d3916f1fc74cfd4986653f6748632db586817a2a8caabea"
31803187 }
31813188 ],
3182 "reason": "0.0.36 reach",
3183 "acceptedAt": "2026-09-08T01:03:14.668Z"
3189 "reason": "version-splits only: dry-run reports 0 code-differs",
3190 "acceptedAt": "2026-09-08T18:13:24.612Z"
31843191 },
31853192 {
31863193 "type": "VERSION_CONFLICT",
……@@ -3194,8 +3201,8 @@
31943201 "tree": "967cf214778dbacd7cc7cc3850ffdff6a6f145b4bd73de741d0938d7d84d33d5"
31953202 }
31963203 ],
3197 "reason": "0.0.36 reach",
3198 "acceptedAt": "2026-09-08T01:03:14.668Z"
3204 "reason": "version-splits only: dry-run reports 0 code-differs",
3205 "acceptedAt": "2026-09-08T18:13:24.612Z"
31993206 },
32003207 {
32013208 "type": "VERSION_CONFLICT",
……@@ -3224,8 +3231,8 @@
32243231 "tree": "35b214bfbf85f8bbcb9f1926470e77f4e240d9b5045b07f81ce83fec24c1380c"
32253232 }
32263233 ],
3227 "reason": "0.0.36 reach",
3228 "acceptedAt": "2026-09-08T01:03:14.668Z"
3234 "reason": "version-splits only: dry-run reports 0 code-differs",
3235 "acceptedAt": "2026-09-08T18:13:24.613Z"
32293236 },
32303237 {
32313238 "type": "VERSION_CONFLICT",
……@@ -3239,8 +3246,8 @@
32393246 "tree": "67c4c05f69061f65a2548595c43282be2da8e0934e6ce347afa034b29cc40c81"
32403247 }
32413248 ],
3242 "reason": "0.0.36 reach",
3243 "acceptedAt": "2026-09-08T01:03:14.668Z"
3249 "reason": "version-splits only: dry-run reports 0 code-differs",
3250 "acceptedAt": "2026-09-08T18:13:24.613Z"
32443251 },
32453252 {
32463253 "type": "VERSION_CONFLICT",
……@@ -3269,8 +3276,8 @@
32693276 "tree": "4ef8a03ba96b1488cb7ee35dc582b14d096ae4b3fab5481186c94516d434fe18"
32703277 }
32713278 ],
3272 "reason": "0.0.36 reach",
3273 "acceptedAt": "2026-09-08T01:03:14.668Z"
3279 "reason": "version-splits only: dry-run reports 0 code-differs",
3280 "acceptedAt": "2026-09-08T18:13:24.613Z"
32743281 },
32753282 {
32763283 "type": "VERSION_CONFLICT",
……@@ -3299,8 +3306,8 @@
32993306 "tree": "5fc49d698f6ddfd653b3cdaa72ec572d6804293e2e9c3e98303f69417115e903"
33003307 }
33013308 ],
3302 "reason": "0.0.36 reach",
3303 "acceptedAt": "2026-09-08T01:03:14.668Z"
3309 "reason": "version-splits only: dry-run reports 0 code-differs",
3310 "acceptedAt": "2026-09-08T18:13:24.613Z"
33043311 },
33053312 {
33063313 "type": "VERSION_CONFLICT",
……@@ -3314,8 +3321,8 @@
33143321 "tree": "d0099e1743480f28a08c7374d20ad5048088475180ab606626b63acd59e365a5"
33153322 }
33163323 ],
3317 "reason": "0.0.36 reach",
3318 "acceptedAt": "2026-09-08T01:03:14.668Z"
3324 "reason": "version-splits only: dry-run reports 0 code-differs",
3325 "acceptedAt": "2026-09-08T18:13:24.613Z"
33193326 },
33203327 {
33213328 "type": "VERSION_CONFLICT",
……@@ -3329,8 +3336,8 @@
33293336 "tree": "60429c7756f039ec2b3e5240f702dee450c1da19ad6de06900a3877c6c72bb52"
33303337 }
33313338 ],
3332 "reason": "0.0.36 reach",
3333 "acceptedAt": "2026-09-08T01:03:14.668Z"
3339 "reason": "version-splits only: dry-run reports 0 code-differs",
3340 "acceptedAt": "2026-09-08T18:13:24.613Z"
33343341 },
33353342 {
33363343 "type": "VERSION_CONFLICT",
……@@ -3359,8 +3366,8 @@
33593366 "tree": "6fc80ad1ccbfb046613f36299032f21a4752e2614ff8d88a7a5bd7ce66fc1293"
33603367 }
33613368 ],
3362 "reason": "0.0.36 reach",
3363 "acceptedAt": "2026-09-08T01:03:14.668Z"
3369 "reason": "version-splits only: dry-run reports 0 code-differs",
3370 "acceptedAt": "2026-09-08T18:13:24.613Z"
33643371 },
33653372 {
33663373 "type": "VERSION_CONFLICT",
……@@ -3379,8 +3386,8 @@
33793386 "tree": "92424acff1a50468df203528e41739f44f602904abc4beb34500ce172c6d0ad2"
33803387 }
33813388 ],
3382 "reason": "0.0.36 reach",
3383 "acceptedAt": "2026-09-08T01:03:14.668Z"
3389 "reason": "version-splits only: dry-run reports 0 code-differs",
3390 "acceptedAt": "2026-09-08T18:13:24.613Z"
33843391 },
33853392 {
33863393 "type": "VERSION_CONFLICT",
……@@ -3404,8 +3411,8 @@
34043411 "tree": "36d01631ddee74ac2ce62971abd7c0ac30562da8dfd6495480da56c534eb80b7"
34053412 }
34063413 ],
3407 "reason": "0.0.36 reach",
3408 "acceptedAt": "2026-09-08T01:03:14.668Z"
3414 "reason": "version-splits only: dry-run reports 0 code-differs",
3415 "acceptedAt": "2026-09-08T18:13:24.613Z"
34093416 },
34103417 {
34113418 "type": "VERSION_CONFLICT",
……@@ -3419,8 +3426,8 @@
34193426 "tree": "bba869f5c85b11bce5671d57a87cd2793b490588867cd07fbe94cad796114970"
34203427 }
34213428 ],
3422 "reason": "0.0.36 reach",
3423 "acceptedAt": "2026-09-08T01:03:14.668Z"
3429 "reason": "version-splits only: dry-run reports 0 code-differs",
3430 "acceptedAt": "2026-09-08T18:13:24.613Z"
34243431 },
34253432 {
34263433 "type": "VERSION_CONFLICT",
……@@ -3434,8 +3441,8 @@
34343441 "tree": "2ecc91a8c3595dda5dfc86ace62a70875520adc4e588a2ac14945dbc4313c450"
34353442 }
34363443 ],
3437 "reason": "0.0.36 reach",
3438 "acceptedAt": "2026-09-08T01:03:14.668Z"
3444 "reason": "version-splits only: dry-run reports 0 code-differs",
3445 "acceptedAt": "2026-09-08T18:13:24.613Z"
34393446 }
34403447 ]
34413448}
lib/mirror.js +41 -2
……@@ -33,11 +33,13 @@ const DEFAULT_CONCURRENCY = 4
3333 * than what exists. What is unbounded is the file count, and a freshly followed
3434 * feed is all of them at once.
3535 */
36export function createMirror ({ feeds, concurrency = DEFAULT_CONCURRENCY, log = () => {} }) {
36export function createMirror ({ feeds, concurrency = DEFAULT_CONCURRENCY, debounceMs = 2000, log = () => {} }) {
3737 if (typeof feeds !== 'function') throw new Error('createMirror: feeds() is required')
3838
3939 let stopped = false
4040 let running = false
41 let timer = null
42 const watched = []
4143
4244 async function mirrorFeed (key, feed) {
4345 // ⛔ `pointers()` and not `list()` or `unresolved()`. Both of those RESOLVE every record, which
……@@ -108,7 +110,44 @@ export function createMirror ({ feeds, concurrency = DEFAULT_CONCURRENCY, log =
108110 return { fetched, failed }
109111 },
110112
111 stop () { stopped = true }
113 /**
114 * Sweep again shortly, coalescing a burst into one pass.
115 *
116 * ⛔ THE BOOT PASS ALONE IS NOT ENOUGH, and an end-to-end test on two machines is what proved
117 * it. At boot a followed feed's log has usually NOT replicated yet — the swarm connection is
118 * still forming — so the sweep runs, correctly finds nothing to do, and never runs again. The
119 * unit tests missed this because a fake hands back its pointers instantly; nothing simulated a
120 * log that arrives thirty seconds later. Records must be fetched when the log ADVANCES, which
121 * is the only moment we know there is something new to fetch.
122 */
123 schedule () {
124 if (stopped || timer) return
125 timer = setTimeout(() => {
126 timer = null
127 this.pass().catch(() => {})
128 }, debounceMs)
129 if (timer && timer.unref) timer.unref()
130 },
131
132 /**
133 * Sweep whenever `base` advances. Autobee emits 'update' on every view change, which is far
134 * more often than the log actually gains records — hence the debounce, and hence `schedule`
135 * rather than `pass` directly.
136 */
137 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 {} }
143 },
144
145 stop () {
146 stopped = true
147 if (timer) { clearTimeout(timer); timer = null }
148 for (const [base, fn] of watched) { try { base.off('update', fn) } catch {} }
149 watched.length = 0
150 }
112151 }
113152}
114153
lib/node.js +12 -7
……@@ -214,11 +214,6 @@ export class FavelaNode {
214214 try { await this._openRoom(r.key, r.slot) } catch {}
215215 }
216216
217 // Re-open the feeds we already follow, so a restart resumes rather than forgetting.
218 for (const name of await this.peer.following()) {
219 try { await this.hold(name) } catch {}
220 }
221
222217 // ⛔ HOLD THE BYTES OF FEEDS WE FOLLOW, before anyone asks to read them.
223218 //
224219 // MEASURED 2026-09-08: this node held five pointers from ashatron and could read none of them.
……@@ -241,8 +236,16 @@ export class FavelaNode {
241236 // ⚠ No logger on this class, and a mirror pass must not invent one. Failures surface where
242237 // they already do: a record whose bytes did not arrive stays UNRESOLVED and the surface says so.
243238 })
244 // ⚠ NOT awaited. A first sweep of a large followed feed must not sit inside boot — the daemon
245 // has to be answering its socket while this runs. Failures are logged and retried next pass.
239 // ⛔ CREATED BEFORE THE FEEDS ARE HELD, so `hold()` can attach a watcher to each as it opens.
240 // Re-open the feeds we already follow, so a restart resumes rather than forgetting.
241 for (const name of await this.peer.following()) {
242 try { await this.hold(name) } catch {}
243 }
244
245 // ⚠ NOT awaited, and NOT sufficient on its own. A first sweep must not sit inside boot. And an
246 // end-to-end run on two machines showed the boot pass alone finds nothing: at boot a followed
247 // feed's log has usually not replicated yet, so the real work is done by the 'update' watcher
248 // attached in hold().
246249 this._mirror.pass().catch(() => {})
247250
248251 return this
……@@ -554,6 +557,8 @@ export class FavelaNode {
554557 if (this._feeds.has(id)) return this._feeds.get(id)
555558 const feed = await this._open('feed:' + id, b4a.from(id, 'hex'), id, bootHead)
556559 this._feeds.set(id, feed)
560 // Mirror this feed's bytes as its log advances — the trigger the boot pass cannot be.
561 if (this._mirror) this._mirror.watch(feed.base)
557562 this.peer.feeds.set(id, feed)
558563 return feed
559564 }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.39",
3 "version": "0.0.40",
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 +83 -0
……@@ -131,3 +131,86 @@ test('⛔ a feed that cannot be listed is skipped, not fatal', async (t) => {
131131
132132 t.alike(fetched, ['a'], 'the healthy feed still mirrored')
133133})
134
135// ⛔ THE BUG THE UNIT TESTS MISSED, found by an end-to-end run on two machines.
136//
137// The first version swept once at boot. At boot a followed feed's log has usually NOT replicated
138// yet — the swarm connection is still forming — so the sweep ran, correctly found nothing, and
139// never ran again. Measured: a record posted on ashatron was still absent on tabertron 2.5 minutes
140// and one restart later, and appeared instantly the moment something READ it.
141//
142// Every fake above hands back its pointers immediately, so none of them could express "the log
143// arrives later". This one does.
144test('★★ a log that advances AFTER the boot pass still gets mirrored', async (t) => {
145 let names = []
146 const fetched = []
147 const feed = {
148 async pointers () {
149 return names.map((n) => ({ key: 'post/' + n, collection: 'post', name: n, pointer: { $oid: 'oid-' + n } }))
150 },
151 store: {
152 async get () { return null },
153 async ensureLocal (_c, n) { fetched.push(n); return { body: n } }
154 }
155 }
156
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 }
164
165 const m = createMirror({ feeds: () => [{ key: 'f1', feed }], debounceMs: 10, log: () => {} })
166 m.watch(base)
167
168 await m.pass()
169 t.is(fetched.length, 0, 'boot pass finds nothing — the log has not arrived')
170
171 names = ['late'] // the log replicates
172 base.emit() // autobee says the view advanced
173 await new Promise((r) => setTimeout(r, 60))
174
175 t.alike(fetched, ['late'], 'and it is mirrored without anyone reading it')
176})
177
178test('★ a burst of updates coalesces into one pass', async (t) => {
179 let passes = 0
180 const feed = {
181 async pointers () { passes++; return [] },
182 store: { async get () { return null }, async ensureLocal () { return null } }
183 }
184 const handlers = []
185 const base = { on: (_e, fn) => handlers.push(fn), off: () => {}, emit: () => handlers.forEach((fn) => fn()) }
186
187 const m = createMirror({ feeds: () => [{ key: 'f1', feed }], debounceMs: 20, log: () => {} })
188 m.watch(base)
189 for (let i = 0; i < 10; i++) base.emit()
190 await new Promise((r) => setTimeout(r, 80))
191
192 t.is(passes, 1, 'ten view changes, one sweep — autobee emits far more often than records arrive')
193})
194
195test('★ stop() detaches the watcher', async (t) => {
196 let passes = 0
197 const feed = {
198 async pointers () { passes++; return [] },
199 store: { async get () { return null }, async ensureLocal () { return null } }
200 }
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 }
207
208 const m = createMirror({ feeds: () => [{ key: 'f1', feed }], debounceMs: 10, log: () => {} })
209 m.watch(base)
210 m.stop()
211 base.emit()
212 await new Promise((r) => setTimeout(r, 40))
213
214 t.is(passes, 0, 'nothing sweeps after stop')
215 t.is(handlers.length, 0, 'and the listener is gone')
216})