OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge commits / 41e1a27

0.9.87: delivery is event-driven, profile changes fire, and the banner reaches AS2
Three things, all the same shape: something that should have been triggered by an event was either on a timer or on nothing at all. ★ THE QUEUE POLLED. createQueuedDeliverer enqueued and returned, and the only drains were boot and a 60s interval — so every activity waited up to a minute for its FIRST attempt. Measured live: published 22:33:54, on the wire 22:34:25. The single-actor bridge called deliver() directly and was instant; the queue bought durability and quietly charged half a minute of latency for it, which is not a trade — the entry is durable before the network is touched either way. The enqueue IS the event, so it kicks a drain, fire-and-forget so the caller still never blocks on the network. The timer is gone rather than kept as a safety net: an unconditional sweep CONCEALS a dead hook, which is exactly how this survived — delivery appeared to work, so nobody noticed nothing fired on the event. Same defect the mirror trigger shipped three times (#66 §3). What remains is event-driven plus reconciliation: the enqueue drives it, start() reconciles a restart's backlog, and drain() is exposed for an operator verb, the posture reproject already has for notes. ⚠ Stated plainly: an entry whose backoff elapses while the process is idle now waits for the next enqueue or an explicit reconcile rather than a tick. That is the honest cost of not polling, and it is visible in pending() rather than silent. A kick arriving mid-drain is no longer dropped, and drain() returns the in-flight promise so `await drain()` still means "drained" — a test caught me breaking exactly that contract. ★ NOTHING RE-FIRED refreshProfile. It ran once when publishing started and never again, on BOTH paths: the primary's onHead drained notes only, and the per-user advance hook did the same. So a changed name, bio or avatar reached Mastodon only if the process happened to restart afterwards. Measured: our actor served a bio prsm had never seen, last Update 21 hours earlier, while posts flowed the whole time — which made it look like a profile-specific bug rather than a missing trigger. A head advance carries profile changes too. It is fingerprint-guarded, so this is a no-op when nothing changed. ★ getProfile now emits `image` from the banner oid, symmetric with `icon` from the avatar. buildActor has always passed profile.image through; nothing ever produced one. 236/236. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
41e1a274a68b026640accd96a26c413dee62ca32
by plankatron <fleeky@gmail.com> · 2026-09-09T22:49:31.000Z

Changed files (5) +106 -21

index.js +20 -4
……@@ -179,7 +179,16 @@ async function main () {
179179 const peer = new DidPeer(corestore, {
180180 did: config.did,
181181 verifyKey,
182 onHead: () => publisher && publisher.onHead(),
182 // ⛔ A HEAD ADVANCE CARRIES PROFILE CHANGES, NOT JUST POSTS. onHead drained notes and
183 // nothing else, and refreshProfile was called exactly once at boot — so a changed display
184 // name, bio or avatar reached Mastodon only if the process happened to restart afterwards.
185 // Measured 2026-09-09: our actor served a bio prsm had never seen, last Update 21h earlier.
186 // refreshProfile is fingerprint-guarded, so this is a no-op when nothing changed.
187 onHead: () => {
188 if (!publisher) return
189 publisher.onHead()
190 publisher.refreshProfile().catch((err) => log({ msg: 'profile refresh failed', error: err.message }))
191 },
183192 onError: (err) => log({ msg: 'peer error', error: err.message })
184193 })
185194
……@@ -387,9 +396,16 @@ async function main () {
387396 // primeWatermark is cheap and returns early the moment it has genuinely primed, so running it
388397 // per advance costs one listNotes on a feed that has already settled and is the only thing
389398 // that makes "try again when the log arrives" true.
390 const advance = async () => { await p.primeWatermark(); p.onHead() }
391 await p.refreshProfile()
392 // watch() fires once immediately, so this covers the already-settled case too.
399 // ⛔ refreshProfile BELONGS IN THE ADVANCE, not before it. It used to run once here and never
400 // again, so a registered user could edit their name, bio or avatar and no follower would ever
401 // be told — posts kept flowing, because those ride onHead, which made the gap look like a
402 // profile-specific bug rather than a missing trigger. Same defect the primary had above.
403 const advance = async () => {
404 await p.primeWatermark()
405 p.onHead()
406 await p.refreshProfile()
407 }
408 // watch() fires once immediately, so this still covers the already-settled case at boot.
393409 if (holder && u.feedKey) holder.watch(u.feedKey, advance)
394410 else await advance()
395411 }
lib/favela-source.js +5 -0
……@@ -102,6 +102,11 @@ export class FavelaSource {
102102 // ⚠ An oid, not a URL — the bytes are already in the overlay and are not copied. This mints the
103103 // URL this bridge serves them at, exactly as an attachment does.
104104 if (fm.avatar) out.icon = { type: 'Image', url: blobUrl({ host: this.host, base: this.base, cid: fm.avatar }) }
105 // ⛔ `image` IS THE BANNER, and it is a different field from `icon` — AS2 has carried both since
106 // the start and Mastodon renders both. Nothing emitted one because nothing upstream stored one,
107 // so every actor this bridge served advertised no header and the destination showed its
108 // placeholder (hyper-social #67 §7.6). buildActor has always passed `profile.image` through.
109 if (fm.banner) out.image = { type: 'Image', url: blobUrl({ host: this.host, base: this.base, cid: fm.banner }) }
105110 return out
106111 }
107112
lib/queue.js +53 -16
……@@ -173,12 +173,12 @@ export class MemoryDeliveryQueue extends BaseQueue {
173173 * this one retries slow (hours) for a server that is actually down. They answer different
174174 * questions and collapsing them would lose one of the answers.
175175 */
176export function createQueuedDeliverer ({ queue, deliver, resolveSigner = null, log = () => {}, now = Date.now, intervalMs = 60_000, setTimer = setInterval, clearTimer = clearInterval }) {
176export function createQueuedDeliverer ({ queue, deliver, resolveSigner = null, log = () => {}, now = Date.now }) {
177177 if (!queue) throw new Error('createQueuedDeliverer: a queue is required')
178178 if (typeof deliver !== 'function') throw new Error('createQueuedDeliverer: a deliver function is required')
179179
180 let timer = null
181 let draining = false
180 let inflight = null
181 let again = false
182182
183183 async function attempt (e) {
184184 try {
……@@ -215,14 +215,29 @@ export function createQueuedDeliverer ({ queue, deliver, resolveSigner = null, l
215215 }
216216 }
217217
218 async function drain () {
219 if (draining) return
220 draining = true
221 try {
222 for (const e of await queue.due(now())) await attempt(e)
223 } finally {
224 draining = false
225 }
218 // ⛔ A KICK THAT ARRIVES MID-DRAIN IS NOT DISCARDED. `if (draining) return` dropped it, which is
219 // safe only when a timer is coming round again to pick up what was missed — i.e. it silently
220 // depended on the sweep this file no longer has. Without that, an activity enqueued while a drain
221 // was in flight would wait for the NEXT unrelated event. Re-running once at the end costs one
222 // `due()` on an empty queue and makes the event sufficient on its own.
223 //
224 // ⛔ AND IT RETURNS THE IN-FLIGHT PROMISE rather than undefined, so `await drain()` still means
225 // "a drain covering my call has finished". Returning early broke exactly that: a caller awaited,
226 // got a resolved promise while the real pass was still running, and asserted against state that
227 // had not been written yet.
228 function drain () {
229 if (inflight) { again = true; return inflight }
230 inflight = (async () => {
231 try {
232 do {
233 again = false
234 for (const e of await queue.due(now())) await attempt(e)
235 } while (again)
236 } finally {
237 inflight = null
238 }
239 })()
240 return inflight
226241 }
227242
228243 return {
……@@ -234,6 +249,15 @@ export function createQueuedDeliverer ({ queue, deliver, resolveSigner = null, l
234249 return { queued: false, id }
235250 }
236251 log({ msg: 'delivery queued', to, inbox, type: activity && activity.type, id })
252 // ★ THE ENQUEUE IS THE EVENT. Delivery used to happen only on a 60s sweep, so everything we
253 // already held in hand waited an average of thirty seconds — measured live 2026-09-09:
254 // published 22:33:54, on the wire 22:34:25. The single-actor bridge called deliver() directly
255 // and was instant; the queue bought durability and quietly charged a minute of latency for it.
256 // Those are not a trade: the entry is durable BEFORE the network is touched either way.
257 //
258 // Fire-and-forget, so the caller still never blocks on the network — the property the comment
259 // above this method has always claimed.
260 drain().catch((err) => log({ msg: 'drain failed', error: err.message }))
237261 return { queued: true, id }
238262 },
239263
……@@ -242,14 +266,27 @@ export function createQueuedDeliverer ({ queue, deliver, resolveSigner = null, l
242266 /** How much is owed. Logged at boot, because a backlog nobody can see is the old bug wearing a hat. */
243267 async pending () { return queue.size() },
244268
245 /** Start draining on a timer. Call once at boot; the first pass picks up a restart's backlog. */
269 /**
270 * Boot reconciliation. NOT a timer.
271 *
272 * ⛔ THE SWEEP CONCEALED A DEAD HOOK, which is the same defect the mirror trigger shipped three
273 * times (hyper-social #66 §3): an unconditional periodic pass makes delivery appear to work and
274 * hides the fact that nothing fires on the event. Delivery was ONLY ever the timer here, and it
275 * took a latency measurement rather than a failure to notice.
276 *
277 * So the shape is event-driven plus reconciliation: the enqueue kicks a drain, this reconciles a
278 * restart's backlog, and `drain()` is exposed for an operator verb — the same posture
279 * `reproject` already has for notes. Nothing polls.
280 *
281 * ⚠ What this gives up, stated plainly: an entry whose retry backoff elapses while the process
282 * is otherwise idle waits for the next enqueue or an explicit reconcile rather than for a tick.
283 * That is the honest cost of not polling, and it is visible in `pending()` rather than silent.
284 */
246285 start () {
247 if (timer) return
248286 drain().catch((err) => log({ msg: 'drain failed', error: err.message }))
249 timer = setTimer(() => { drain().catch((err) => log({ msg: 'drain failed', error: err.message })) }, intervalMs)
250 if (timer && timer.unref) timer.unref()
251287 },
252288
253 stop () { if (timer) { clearTimer(timer); timer = null } }
289 /** Nothing to stop any more — kept so callers that shut down cleanly do not need to change. */
290 stop () {}
254291 }
255292}
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "ap-bridge",
3 "version": "0.9.86",
3 "version": "0.9.87",
44 "description": "ActivityPub membrane \u2014 a second projection of the self-custody atproto peer",
55 "type": "module",
66 "main": "index.js",
test/queue.test.js +27 -0
……@@ -280,3 +280,30 @@ test('★ a delivery with no signer still uses the process default, exactly as b
280280 t.is(seen.length, 1)
281281 t.absent(seen[0].signer, 'the primary keeps the deliverer it always had')
282282})
283
284// ⛔ THE TIMER IS FOR RETRY, NOT FOR FIRST DELIVERY. `deliver()` enqueued and returned, and the only
285// things that ever drained were boot and a 60s interval — so every activity waited up to a minute
286// before its first attempt, averaging thirty seconds. Measured live on 2026-09-09: published at
287// 22:33:54, on the wire at 22:34:25.
288//
289// The single-actor bridge called deliver() directly and was instant; the queue was added for
290// durability and silently bought a minute of latency with it. Durability and latency are not a
291// trade — the entry is written before the network is touched either way, and the drain is kicked
292// straight after, fire-and-forget so the caller still never blocks on the network.
293test('★ enqueueing kicks a drain rather than waiting for the timer', async (t) => {
294 const sent = []
295 const q = new MemoryDeliveryQueue()
296 const d = createQueuedDeliverer({
297 queue: q,
298 deliver: async (x) => { sent.push(x); return { ok: true } },
299 log: () => {}
300 })
301
302 await d.deliver({ to: 'https://prsm.space/users/fleeky', activity: { type: 'Create', id: 'a1' } })
303 // Let the fire-and-forget drain settle.
304 await new Promise((r) => setImmediate(r))
305 await new Promise((r) => setImmediate(r))
306
307 t.is(sent.length, 1, '⛔ delivered on the enqueue event — there is no timer to fall back on')
308 t.is(await d.pending(), 0, 'and the entry is no longer owed')
309})