| … | … | @@ -138,8 +138,6 @@ export class Publisher { |
| 138 | 138 | this.log({ msg: 'profile refresh: nobody to tell', actor: this.actorId }) |
| 139 | 139 | return |
| 140 | 140 | } |
| 141 | await this.state.setProfileFingerprint(fp) | |
| 142 | ||
| 143 | 141 | const update = { |
| 144 | 142 | '@context': ['https://www.w3.org/ns/activitystreams', { atproto: 'https://atproto.com/ns#' }], |
| 145 | 143 | id: `${this.actorId.replace(/\/actor$/, '')}/activities/${this.newId()}`, |
| … | … | @@ -153,11 +151,19 @@ export class Publisher { |
| 153 | 151 | object: { ...doc, updated: new Date().toISOString() } |
| 154 | 152 | } |
| 155 | 153 | 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) => | |
| 157 | 155 | 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 }) | |
| 160 | 158 | )) |
| 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 }) | |
| 161 | 167 | } |
| 162 | 168 | |
| 163 | 169 | // The onHead handler. Serialised: a head can advance again while we are still delivering, and |
| … | … | @@ -1,7 +1,7 @@ |
| 1 | 1 | { |
| 2 | 2 | "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", | |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| 7 | 7 | "scripts": { |
| … | … | @@ -57,3 +57,35 @@ test('⛔⛔ a run with no followers does not mark the profile as sent', async ( |
| 57 | 57 | await mk().refreshProfile() |
| 58 | 58 | t.is(sent.length, 1, 'and when a follower arrives, it IS sent — not swallowed by a stale fingerprint') |
| 59 | 59 | }) |
| 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. | |
| 68 | test('⛔⛔ 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 | }) |