From 206506f5e9b87d0a1751807a8d9bc911743d3cf0 Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 10:45:18 +0200 Subject: [PATCH] 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 (-again-), 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 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- FEDERATION.md | 7 ++++--- PrivaPub.Tests/Federation/InboxGapTests.cs | 10 ++++++++-- PrivaPub/Domain/Social/FollowService.cs | 10 +++++++++- PrivaPub/Federation/Inbox/Handlers/AcceptHandler.cs | 5 ++++- docs/INTEROP.md | 3 +++ 5 files changed, 28 insertions(+), 7 deletions(-) diff --git a/FEDERATION.md b/FEDERATION.md index 6e2ae7f..9459d97 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -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 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. -- **Follow requests** still unanswered are sent again after 15 minutes, an hour, 6 hours, a day, two and four days, the - same activity each time: a server can take a Follow and lose its answer (Lemmy 1.0 sends nothing it queued for a - server before it started sending there), and one that holds the follow already answers the copy. +- **Follow requests** still unanswered are sent again after 15 minutes, an hour, 6 hours, a day, two and four days: a + server can take a Follow and lose its answer (Lemmy 1.0 sends nothing it queued for a server before it started + sending there), and one that holds the follow answers it again. Each is the same follow under a new id + (`…/follow--again-`), 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 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, diff --git a/PrivaPub.Tests/Federation/InboxGapTests.cs b/PrivaPub.Tests/Federation/InboxGapTests.cs index d0e512d..56b2fe9 100644 --- a/PrivaPub.Tests/Federation/InboxGapTests.cs +++ b/PrivaPub.Tests/Federation/InboxGapTests.cs @@ -68,12 +68,18 @@ namespace PrivaPub.Tests.Federation Assert.Equal(0, await After(TimeSpan.FromMinutes(30))); Assert.Equal(1, await After(TimeSpan.FromMinutes(77))); Assert.Equal(3, await Follows()); - Assert.All(await _harness.Outgoing(quiet.Id + "/inbox"), a => Assert.Equal(following.FollowActivityURI, a["id"]!.GetValue())); + // 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()).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 await DB.Default.Update().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))); - await DB.Default.Update().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().OneAsync(following.ID, token)).State); Assert.Equal(0, await After(TimeSpan.FromDays(30))); } diff --git a/PrivaPub/Domain/Social/FollowService.cs b/PrivaPub/Domain/Social/FollowService.cs index a107b92..d5cf1ad 100644 --- a/PrivaPub/Domain/Social/FollowService.cs +++ b/PrivaPub/Domain/Social/FollowService.cs @@ -298,7 +298,10 @@ namespace PrivaPub.Domain.Social var inbox = following.TargetInboxURL ?? (await _remoteActors.GetActor(following.TargetActorURI, refresh: false, token))?.InboxURL; 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)); sent++; } @@ -308,6 +311,11 @@ namespace PrivaPub.Domain.Social 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() { ["@context"] = ActivityPubRenderer.ActivityStreams, diff --git a/PrivaPub/Federation/Inbox/Handlers/AcceptHandler.cs b/PrivaPub/Federation/Inbox/Handlers/AcceptHandler.cs index 5109df9..6a243a1 100644 --- a/PrivaPub/Federation/Inbox/Handlers/AcceptHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/AcceptHandler.cs @@ -53,7 +53,10 @@ namespace PrivaPub.Federation.Inbox.Handlers { if (follow is JsonObject && Value(follow, "type") is { } type && type != "Follow") 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 ? default : await dbEntities.Followings.Match(f => f.FollowActivityURI == followId && f.TargetActorURI == target.ActorURI).ExecuteFirstAsync(token); diff --git a/docs/INTEROP.md b/docs/INTEROP.md index 098e86d..1b8f76f 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -533,6 +533,9 @@ What it showed: - **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 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 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