A follow request sent again carries a new id

Lemmy keeps the ids of the activities it received and never answers one again, so resending the same Follow could not
heal a follow whose Accept it lost: the village's community follow stayed pending through two resends. Each resend is
now the same follow under its own id (<follow id>-again-<n>), and an Accept naming any of them answers the follow;
the Undo still embeds the follow, so servers match it by its actor and object. Sent this way, the stuck follow was
accepted at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 10:45:18 +02:00
1 parent a5d493a8c9
commit 206506f5e9
5 files changed
+28 -7

No files matched your search

+4 -3
View File
@@ -230,9 +230,10 @@ Posts with a location (shown to nearby users of this server) never leave the ser
is read again from its author's server, a deletion of a public or unlisted post is applied once that server answers is read again from its author's server, a deletion of a public or unlisted post is applied once that server answers
404 or 410, and anything else is dropped. LD signatures are not verified. A reply forwarded this way is kept when 404 or 410, and anything else is dropped. LD signatures are not verified. A reply forwarded this way is kept when
someone here follows the author of the post it answers, as Mastodon does. someone here follows the author of the post it answers, as Mastodon does.
- **Follow requests** still unanswered are sent again after 15 minutes, an hour, 6 hours, a day, two and four days, the - **Follow requests** still unanswered are sent again after 15 minutes, an hour, 6 hours, a day, two and four days: a
same activity each time: a server can take a Follow and lose its answer (Lemmy 1.0 sends nothing it queued for a server can take a Follow and lose its answer (Lemmy 1.0 sends nothing it queued for a server before it started
server before it started sending there), and one that holds the follow already answers the copy. sending there), and one that holds the follow answers it again. Each is the same follow under a new id
(`…/follow-<id>-again-<n>`), since Lemmy ignores an id it has seen; an answer naming any of them counts.
- **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three - **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three
redirects and read at most 1 MB. redirects and read at most 1 MB.
- **Threads.** A reply's missing parents are fetched, up to 10 levels. When someone here opens a public remote thread, - **Threads.** A reply's missing parents are fetched, up to 10 levels. When someone here opens a public remote thread,
+8 -2
View File
@@ -68,12 +68,18 @@ namespace PrivaPub.Tests.Federation
Assert.Equal(0, await After(TimeSpan.FromMinutes(30))); Assert.Equal(0, await After(TimeSpan.FromMinutes(30)));
Assert.Equal(1, await After(TimeSpan.FromMinutes(77))); Assert.Equal(1, await After(TimeSpan.FromMinutes(77)));
Assert.Equal(3, await Follows()); Assert.Equal(3, await Follows());
Assert.All(await _harness.Outgoing(quiet.Id + "/inbox"), a => Assert.Equal(following.FollowActivityURI, a["id"]!.GetValue<string>())); // each one again under its own id (Lemmy never answers an id it has seen), and an answer naming it counts
var sentIds = (await _harness.Outgoing(quiet.Id + "/inbox")).Select(a => a["id"]!.GetValue<string>()).ToList();
Assert.Equal(new[] { following.FollowActivityURI, following.FollowActivityURI + "-again-1", following.FollowActivityURI + "-again-2" }, sentIds);
// a request older than the count is sent again too; an answered one never // a request older than the count is sent again too; an answered one never
await DB.Default.Update<Following>().MatchID(following.ID).Modify(b => b.Unset(f => f.Resent)).Modify(b => b.Unset(f => f.ResentAt)).ExecuteAsync(token); await DB.Default.Update<Following>().MatchID(following.ID).Modify(b => b.Unset(f => f.Resent)).Modify(b => b.Unset(f => f.ResentAt)).ExecuteAsync(token);
Assert.Equal(1, await After(TimeSpan.FromDays(1))); Assert.Equal(1, await After(TimeSpan.FromDays(1)));
await DB.Default.Update<Following>().MatchID(following.ID).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token); await _harness.Deliver(quiet, "/human-centipede", new JsonObject
{
["id"] = NewId(quiet, "accepts"), ["type"] = "Accept", ["actor"] = quiet.Id, ["object"] = following.FollowActivityURI + "-again-2"
});
Assert.Equal(FollowState.Accepted, (await DB.Default.Find<Following>().OneAsync(following.ID, token)).State);
Assert.Equal(0, await After(TimeSpan.FromDays(30))); Assert.Equal(0, await After(TimeSpan.FromDays(30)));
} }
+9 -1
View File
@@ -298,7 +298,10 @@ namespace PrivaPub.Domain.Social
var inbox = following.TargetInboxURL ?? (await _remoteActors.GetActor(following.TargetActorURI, refresh: false, token))?.InboxURL; var inbox = following.TargetInboxURL ?? (await _remoteActors.GetActor(following.TargetActorURI, refresh: false, token))?.InboxURL;
if (follower != default && !string.IsNullOrEmpty(inbox)) if (follower != default && !string.IsNullOrEmpty(inbox))
{ {
await _delivery.Enqueue(follower, new[] { inbox }, FollowActivity(follower, following), token, // under a new id each time: Lemmy keeps the ids it received and never answers one again
var again = FollowActivity(follower, following);
again["id"] = AgainId(following.FollowActivityURI, following.Resent + 1);
await _delivery.Enqueue(follower, new[] { inbox }, again, token,
again: "resend-" + now.ToString("yyyyMMddHHmm", System.Globalization.CultureInfo.InvariantCulture)); again: "resend-" + now.ToString("yyyyMMddHHmm", System.Globalization.CultureInfo.InvariantCulture));
sent++; sent++;
} }
@@ -308,6 +311,11 @@ namespace PrivaPub.Domain.Social
return sent; return sent;
} }
// a follow sent again is the same follow under a new id, which an answer may name (AcceptHandler.FindFollowing)
public const string Again = "-again-";
public static string AgainId(string followActivityUri, int resend) => $"{followActivityUri}{Again}{resend}";
static JsonObject FollowActivity(LocalActor follower, Following following) => new() static JsonObject FollowActivity(LocalActor follower, Following following) => new()
{ {
["@context"] = ActivityPubRenderer.ActivityStreams, ["@context"] = ActivityPubRenderer.ActivityStreams,
@@ -53,7 +53,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
{ {
if (follow is JsonObject && Value(follow, "type") is { } type && type != "Follow") if (follow is JsonObject && Value(follow, "type") is { } type && type != "Follow")
return default; return default;
var followId = Id(follow); // (a follow sent again names the first one under a new id: FollowService.AgainId)
var followId = Id(follow) is { } named && named.IndexOf(Domain.Social.FollowService.Again, StringComparison.Ordinal) is var at and > 0
? named[..at]
: Id(follow);
var byId = followId == default var byId = followId == default
? default ? default
: await dbEntities.Followings.Match(f => f.FollowActivityURI == followId && f.TargetActorURI == target.ActorURI).ExecuteFirstAsync(token); : await dbEntities.Followings.Match(f => f.FollowActivityURI == followId && f.TargetActorURI == target.ActorURI).ExecuteFirstAsync(token);
+3
View File
@@ -533,6 +533,9 @@ What it showed:
- **Every bare `Announce{object}` is answered 400** (`Failed to parse object`: Lemmy dereferences it expecting an - **Every bare `Announce{object}` is answered 400** (`Failed to parse object`: Lemmy dereferences it expecting an
activity), as Lemmy answers the compatibility `Announce(Page)` it sends itself. Both 400s show up as dead deliveries activity), as Lemmy answers the compatibility `Announce(Page)` it sends itself. Both 400s show up as dead deliveries
in the statistics. in the statistics.
- **It keeps the ids of the activities it received and never answers one again.** A follow it took and whose Accept it
lost (sent before its worker for our server started) stayed pending through two resends of the same Follow; sent
under a new id (2026-10-05), it was accepted at once (the village's community follow, pending since 05:06).
- **Votes travel only to the community**, which relays them as `Announce{Like}` and `Announce{Dislike}`: counted since - **Votes travel only to the community**, which relays them as `Announce{Like}` and `Announce{Dislike}`: counted since
ed08f80, and a vote turned the other way replaces the first since bbeeda7. A moderator's removal, lock and ban come ed08f80, and a vote turned the other way replaces the first since bbeeda7. A moderator's removal, lock and ban come
the same way and are applied since 2026-10-05 (the removal was applied all along; the scenario gave up before Lemmy's the same way and are applied since 2026-10-05 (the removal was applied all along; the scenario gave up before Lemmy's