OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge commits / 9da6745

0.9.86: a profile Update that reached nobody was recorded as sent
The no-followers guard (0.9.71) fixed "recorded as told when nobody was told". The same defect survived one step later: the fingerprint was stored BEFORE the deliveries, and every delivery is caught and logged rather than thrown — so a run where all of them failed also recorded "already sent this", and the profile became permanently un-sendable without `force`. Same silence, different cause. Now the fingerprint is written after the sends and only if at least one landed. A run that reached nobody says so and leaves the profile sendable. This is the shape ashatron's avatar has: /ap/users/ashatron/actor advertises an icon that serves (200, image/png, 1377 B) and no follower ever re-fetched it, while display_name and note did correct themselves. Whether this is the cause there is not proven — the fix is right regardless, because the state was lying about what happened. Test written first and red for the right reason: the fingerprint was recorded despite every delivery throwing, and the retry short-circuited. 235/235 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9da6745fe4e1c7e9a617e6e987ecd630fb55f7a0
by plankatron <fleeky@gmail.com> · 2026-09-09T22:12:57.000Z

Changed files (3) +45 -7

lib/publisher.js +11 -5
……@@ -138,8 +138,6 @@ export class Publisher {
138138 this.log({ msg: 'profile refresh: nobody to tell', actor: this.actorId })
139139 return
140140 }
141 await this.state.setProfileFingerprint(fp)
142
143141 const update = {
144142 '@context': ['https://www.w3.org/ns/activitystreams', { atproto: 'https://atproto.com/ns#' }],
145143 id: `${this.actorId.replace(/\/actor$/, '')}/activities/${this.newId()}`,
……@@ -153,11 +151,19 @@ export class Publisher {
153151 object: { ...doc, updated: new Date().toISOString() }
154152 }
155153 this.log({ msg: 'profile changed — sending Update', followers: recipients.length })
156 await Promise.all(recipients.map((to) =>
154 const landed = await Promise.all(recipients.map((to) =>
157155 this.deliver({ to, activity: update })
158 .then(() => this.log({ msg: 'profile Update delivered', to }))
159 .catch((err) => this.log({ msg: 'profile Update failed', to, error: err.message }))
156 .then(() => { this.log({ msg: 'profile Update delivered', to }); return true })
157 .catch((err) => { this.log({ msg: 'profile Update failed', to, error: err.message }); return false })
160158 ))
159
160 // ⛔ RECORDED AFTER, AND ONLY IF SOMETHING LANDED. The guard above fixed "nobody to tell"; this
161 // is the same defect one step later. Every delivery is caught and logged rather than thrown, so
162 // a run where all of them failed used to store the fingerprint anyway and the profile became
163 // permanently un-sendable — the same silence, from a different cause. A receiver that was told
164 // is the only thing that makes this true.
165 if (landed.some(Boolean)) return this.state.setProfileFingerprint(fp)
166 this.log({ msg: 'profile Update reached nobody — not recording it as sent', followers: recipients.length })
161167 }
162168
163169 // The onHead handler. Serialised: a head can advance again while we are still delivering, and
package.json +2 -2
……@@ -1,7 +1,7 @@
11{
22 "name": "ap-bridge",
3 "version": "0.9.85",
4 "description": "ActivityPub membrane — a second projection of the self-custody atproto peer",
3 "version": "0.9.86",
4 "description": "ActivityPub membrane \u2014 a second projection of the self-custody atproto peer",
55 "type": "module",
66 "main": "index.js",
77 "scripts": {
test/profile-update.test.js +32 -0
……@@ -57,3 +57,35 @@ test('⛔⛔ a run with no followers does not mark the profile as sent', async (
5757 await mk().refreshProfile()
5858 t.is(sent.length, 1, 'and when a follower arrives, it IS sent — not swallowed by a stale fingerprint')
5959})
60
61// ⛔⛔ THE SAME DEFECT ONE STEP LATER. The no-followers guard above fixed "recorded as told when
62// nobody was told". But the fingerprint is still stored BEFORE the deliveries, and every delivery
63// failure is caught and logged rather than propagated — so a run where every recipient failed also
64// records "already sent this", and the profile becomes un-sendable without `force`.
65//
66// This is the shape ashatron's un-refreshed avatar has: the actor advertises an icon that serves,
67// and no follower ever re-fetched it.
68test('⛔⛔ a run where every delivery FAILS does not mark the profile as sent', async (t) => {
69 let attempts = 0
70 const followers = new MemoryFollowerStore(['https://prsm.space/users/fleeky'])
71 const state = new MemoryState([], true)
72 const mk = (fail) => new Publisher({
73 records: { async getProfile () { return { name: 'ashes' } }, async listNotes () { return { notes: [], cursor: null } } },
74 followers,
75 deliver: async () => { attempts++; if (fail) throw new Error('connect ECONNREFUSED') },
76 actorId: 'https://ap.7rnx.net/ap/users/ashatron/actor',
77 newId: () => 'id1',
78 log: () => {},
79 state,
80 buildActorDoc: async () => ({ id: 'https://ap.7rnx.net/ap/users/ashatron/actor', type: 'Person', name: 'ashes' })
81 })
82
83 await mk(true).refreshProfile()
84 t.is(attempts, 1, 'it tried')
85 t.absent(await state.getProfileFingerprint(), '⛔ nothing landed, so nothing may be recorded as sent')
86
87 // The retry is the point: a profile that failed to reach anybody must still be sendable.
88 await mk(false).refreshProfile()
89 t.is(attempts, 2, 'a later run tries again rather than short-circuiting')
90 t.ok(await state.getProfileFingerprint(), 'and records it once somebody was actually told')
91})