Skip to content

Commit 0052334

Browse files
committed
fix(leave): classify benign rejections by membership state, cover departing co-super-admins
Two refinements to the leave writer's edge handling: - Benign leave rejections are no longer treated uniformly when the consent-hide fails afterwards. Each benign case is classified by what it implies for the membership, matching libxmtp's leave_group validation order: not-a-member, already-in-pending-leave-list, and the missing-group cases mean the membership is already over or its removal already pending, so a hide failure there still surfaces as hideFailedAfterLeave and the caller announces the leave. Only the single-member rejection keeps the user a member (libxmtp raises it only for a sole occupant who is a member), so only that case still propagates the raw hide error as a failed, retryable leave. - relinquishSuperAdminIfNeeded promoted a successor only when the leaver was the sole super admin, so with a co-super-admin whose own departure was pending the self-demotion could leave zero active super admins once that leave finalized. The promotion branch now runs when no other super admin remains viable (none exists, or every other one has an announced departure marker). When that happens and no candidate remains to promote, the leave aborts with noViableSuccessor instead of orphaning the group. Four writer tests pin the two hide-failure classifications (already-out and group-gone), the promote-before-demote sequence with a departing co-super-admin, and the abort when no candidate remains.
1 parent 633db8a commit 0052334

3 files changed

Lines changed: 211 additions & 63 deletions

File tree

Convos/Conversation Detail/ConversationViewModel.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4277,10 +4277,10 @@ extension ConversationViewModel {
42774277
)
42784278
self.finishLeave()
42794279
} catch ConversationLeaveError.hideFailedAfterLeave(_, let underlying) {
4280-
// The MLS self-removal committed; only the local consent-hide
4281-
// failed afterwards. The user is off the group either way, so
4282-
// surface the leave and let a later consent sync converge the
4283-
// hide.
4280+
// The user's membership already ended (the MLS self-removal
4281+
// committed, or it was already over or pending); only the
4282+
// local consent-hide failed afterwards. Surface the leave and
4283+
// let a later consent sync converge the hide.
42844284
Log.error("Left convo but hiding it failed: \(underlying.localizedDescription)")
42854285
self.finishLeave()
42864286
} catch {

ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationLeaveWriter.swift

Lines changed: 112 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ public protocol ConversationLeaveWriterProtocol: Sendable {
99
/// the MLS remove-commit finalizes async.
1010
///
1111
/// The protocol forbids a super admin from leaving (and from being
12-
/// removed), so a super-admin leaver is demoted first. When the leaver is
13-
/// also the sole super admin, the role is transferred to a remaining
14-
/// member before the demotion so the group always keeps at least one
15-
/// super admin. `successorCandidates` is the remaining membership
16-
/// (excluding the leaver); the writer applies a human-preferred,
17-
/// agent-fallback tenure policy to pick who to promote.
12+
/// removed), so a super-admin leaver is demoted first. When no other
13+
/// super admin would remain after the demotion (the leaver is the sole
14+
/// super admin, or every other super admin has announced their own
15+
/// departure), the role is transferred to a remaining member before the
16+
/// demotion so the group always keeps at least one super admin.
17+
/// `successorCandidates` is the remaining membership (excluding the
18+
/// leaver); the writer applies a human-preferred, agent-fallback tenure
19+
/// policy to pick who to promote.
1820
func leave(
1921
conversation: Conversation,
2022
successorCandidates: [LeaveSuccessorCandidate]
@@ -52,17 +54,20 @@ protocol LeaveGroupOperationsProtocol: Sendable {
5254
public enum ConversationLeaveError: LocalizedError {
5355
case conversationNotFound(String)
5456
case notGroupConversation(String)
55-
/// The MLS self-removal committed in this call, but the optimistic
56-
/// consent-hide failed afterwards. The user is off the group, so callers
57-
/// should treat the conversation as left; the hide converges on a later
58-
/// consent sync. Not thrown when the leave was benignly swallowed (the
59-
/// raw hide error propagates instead, so the leave reads as failed and
60-
/// stays retryable).
57+
/// The user's membership already ended (the MLS self-removal committed
58+
/// in this call, or a benign rejection showed it was already over or
59+
/// already pending), but the optimistic consent-hide failed afterwards.
60+
/// The user is off the group either way, so callers should treat the
61+
/// conversation as left; the hide converges on a later consent sync. Not
62+
/// thrown for the one benign rejection that keeps the user a member (the
63+
/// single-member group): there the raw hide error propagates instead, so
64+
/// the leave reads as failed and stays retryable.
6165
case hideFailedAfterLeave(String, underlying: any Error)
62-
/// The leaver is the sole super admin and no successor could be promoted:
63-
/// every candidate announced their own departure since the candidate
64-
/// snapshot was taken. The leave aborts so the group is not left with a
65-
/// departing member as its only super admin.
66+
/// The leaver is the last super admin not already departing, and no
67+
/// successor could be promoted: every candidate announced their own
68+
/// departure since the candidate snapshot was taken, or none remains.
69+
/// The leave aborts so the group is not left without an active super
70+
/// admin.
6671
case noViableSuccessor(String)
6772

6873
public var errorDescription: String? {
@@ -146,15 +151,13 @@ final class ConversationLeaveWriter: ConversationLeaveWriterProtocol, @unchecked
146151
) async throws {
147152
// Self-remove from the MLS roster. Benign outcomes are logged and
148153
// swallowed so the optimistic consent-hide below still runs and the
149-
// conversation leaves the UI regardless:
150-
// - we're the last member, so libxmtp rejects the 1 -> 0 commit,
151-
// - the group is already gone locally or another admin removed us
152-
// first (conversationNotFound / NotFound::MlsGroup) -- the leave's
153-
// goal is already achieved, only the local hide remains.
154-
// The benign filter covers the whole flow because the not-found case
155-
// also surfaces from the pre-leave super-admin lookup, not just from
156-
// leaveGroup itself.
157-
var leaveCommitted = true
154+
// conversation leaves the UI regardless. Each benign case is also
155+
// classified by what it implies for the membership (see
156+
// `benignLeaveOutcome(for:)`) because that decides what a hide
157+
// failure afterwards means. The benign filter covers the whole flow
158+
// because the not-found case also surfaces from the pre-leave
159+
// super-admin lookup, not just from leaveGroup itself.
160+
var membershipEnded = true
158161
do {
159162
let myInboxId = try await operations.currentInboxId()
160163
try await relinquishSuperAdminIfNeeded(
@@ -164,33 +167,34 @@ final class ConversationLeaveWriter: ConversationLeaveWriterProtocol, @unchecked
164167
)
165168
try await operations.leaveGroup(conversationId: conversation.id)
166169
} catch {
167-
guard Self.isBenignLeaveError(error) else { throw error }
168-
leaveCommitted = false
170+
guard let outcome = Self.benignLeaveOutcome(for: error) else { throw error }
171+
membershipEnded = outcome == .membershipEnded
169172
Log.info("Leave skipped for \(conversation.id): \(error.localizedDescription)")
170173
}
171174

172175
// Optimistic hide, reusing the existing consent-hide path: consent
173176
// `.denied` + push-topic unsubscribe + local row hide. The MLS
174177
// remove-commit is finalized async by an authorized admin/agent.
175-
// When the self-removal committed above, a failure here is surfaced
176-
// as a typed error: the caller must still treat the conversation as
177-
// left rather than as a failed leave. When the leave was benignly
178-
// swallowed instead, nothing committed in this call, so the raw hide
179-
// error propagates and the leave reads as failed and retryable;
180-
// announcing a completed leave here could be false (a single-member
181-
// rejection leaves the user a member of a still-visible group).
178+
// When the membership already ended above (the self-removal
179+
// committed, or a benign rejection showed it was already over), a
180+
// failure here is surfaced as a typed error: the caller must still
181+
// treat the conversation as left rather than as a failed leave. When
182+
// the user is still a member instead (the single-member rejection),
183+
// the raw hide error propagates and the leave reads as failed and
184+
// retryable; announcing a completed leave there would be false.
182185
do {
183186
try await consentWriter.delete(conversation: conversation)
184187
} catch {
185-
guard leaveCommitted else { throw error }
188+
guard membershipEnded else { throw error }
186189
throw ConversationLeaveError.hideFailedAfterLeave(conversation.id, underlying: error)
187190
}
188191
}
189192

190193
/// The protocol rejects `leaveGroup()` while the leaver is a super admin
191194
/// (super admins cannot be removed), so a super-admin leaver must be
192195
/// demoted before the leave. Protects the "group always keeps at least
193-
/// one super admin" invariant: when the leaver is the sole super admin,
196+
/// one super admin" invariant: when no other super admin remains viable -
197+
/// there is none, or every other one has announced their own departure -
194198
/// the preferred successor is promoted before the self-demotion. Awaited
195199
/// rather than best-effort: if the transfer or demotion can't land we
196200
/// abort the leave rather than strand the group with no super admin or
@@ -203,22 +207,49 @@ final class ConversationLeaveWriter: ConversationLeaveWriterProtocol, @unchecked
203207
let superAdmins = try await operations.superAdminInboxIds(conversationId: conversationId)
204208
guard superAdmins.contains(myInboxId) else { return }
205209

206-
if superAdmins.count == 1 {
210+
let otherSuperAdmins = superAdmins.filter { $0 != myInboxId }
211+
let keepsAViableSuperAdmin: Bool = try await hasViableOtherSuperAdmin(
212+
among: otherSuperAdmins,
213+
conversationId: conversationId
214+
)
215+
if !keepsAViableSuperAdmin {
207216
let ordered = Self.orderedSuccessorInboxIds(from: successorCandidates, excluding: myInboxId)
208-
guard !ordered.isEmpty else {
217+
if ordered.isEmpty && otherSuperAdmins.isEmpty {
209218
// No one left to promote: the leaver is effectively the last
210219
// member, so keep the role and let `leaveGroup()` resolve it
211220
// (the 1 -> 0 commit is rejected as a benign error).
212221
Log.warning("Leaving \(conversationId) as sole super admin with no successor to promote")
213222
return
214223
}
224+
guard !ordered.isEmpty else {
225+
// Every other super admin announced their own departure and
226+
// no other member remains to promote: demoting self would let
227+
// the group finalize into zero super admins once those leaves
228+
// complete.
229+
throw ConversationLeaveError.noViableSuccessor(conversationId)
230+
}
215231
try await promoteFirstViableSuccessor(from: ordered, conversationId: conversationId)
216232
}
217233

218234
try await operations.demoteFromSuperAdmin(inboxId: myInboxId, conversationId: conversationId)
219235
Log.info("Demoted self from super admin before leaving \(conversationId)")
220236
}
221237

238+
/// True when at least one of the other super admins has not announced
239+
/// their own departure, so the group still has a super admin after the
240+
/// leaver's self-demotion. A departing super admin does not count: their
241+
/// pending removal would finalize the group into zero super admins.
242+
private func hasViableOtherSuperAdmin(
243+
among inboxIds: [String],
244+
conversationId: String
245+
) async throws -> Bool {
246+
for inboxId in inboxIds {
247+
let departing = try await hasAnnouncedDeparture(inboxId: inboxId, conversationId: conversationId)
248+
if !departing { return true }
249+
}
250+
return false
251+
}
252+
222253
/// Tries candidates in preference order. The snapshot behind the
223254
/// candidates is taken before the leave runs, so a candidate can have
224255
/// left the group meanwhile and their promotion is rejected; falling
@@ -298,34 +329,61 @@ final class ConversationLeaveWriter: ConversationLeaveWriterProtocol, @unchecked
298329
}
299330
}
300331

332+
/// What a benign leave rejection implies for the user's membership. The
333+
/// distinction decides what a subsequent consent-hide failure means for
334+
/// the caller.
335+
private enum BenignLeaveOutcome {
336+
/// The membership is already over, or its removal is already pending
337+
/// finalization - the same state a successful `leaveGroup()` leaves
338+
/// behind. A hide failure afterwards still means the user is off the
339+
/// group.
340+
case membershipEnded
341+
/// The rejection keeps the user a member: they are the group's sole
342+
/// member and libxmtp forbids the 1 -> 0 commit. Only the local hide
343+
/// ends this membership, so a hide failure means the leave failed.
344+
case stillMember
345+
}
346+
301347
/// libxmtp surfaces MLS failures as FFI errors whose full message is only
302348
/// visible via `String(describing:)`. `LeaveCantProcessed` is the wrapper
303349
/// for every leave validation failure - including super-admin and DM
304350
/// rejections that mean the user is still a member - so matching on the
305351
/// wrapper name alone would turn those into a silent local hide. Only the
306352
/// specific rejections whose goal is already achieved (or unachievable by
307-
/// protocol) are benign, matched by their stable libxmtp messages:
308-
/// - "only one member": we're the last member and the 1 -> 0 invariant
309-
/// rejects the commit; the group is dead by protocol.
310-
/// - "only a member of the group can send a leave request": a concurrent
311-
/// removal already took us out before the leave-request landed.
312-
/// - "already exists in the pending leave list": a leave-request from a
313-
/// retry or another installation is already awaiting finalization.
314-
/// - `NotFound::MlsGroup`: a concurrent remove already deleted the group.
315-
/// - `conversationNotFound`: the client no longer has the group at all
316-
/// (another admin removed us and the welcome/group was purged); the
317-
/// local hide is all that's left to do.
318-
private static func isBenignLeaveError(_ error: any Error) -> Bool {
353+
/// protocol) are benign, matched by their stable libxmtp messages, and
354+
/// each carries what it implies for the membership:
355+
/// - "only one member" (still a member): libxmtp raises it only when the
356+
/// user is a member and the sole occupant; the 1 -> 0 invariant rejects
357+
/// the commit and the group is dead by protocol, but only the local
358+
/// hide removes it from view.
359+
/// - "only a member of the group can send a leave request" (ended): a
360+
/// concurrent removal already took us out before the leave-request
361+
/// landed.
362+
/// - "already exists in the pending leave list" (ended): a leave-request
363+
/// from a retry or another installation is already awaiting
364+
/// finalization.
365+
/// - `NotFound::MlsGroup` (ended): a concurrent remove already deleted
366+
/// the group.
367+
/// - `conversationNotFound` (ended): the client no longer has the group
368+
/// at all (another admin removed us and the welcome/group was purged);
369+
/// the local hide is all that's left to do.
370+
/// Returns nil for any other error: not benign, the leave failed.
371+
private static func benignLeaveOutcome(for error: any Error) -> BenignLeaveOutcome? {
319372
if case ConversationLeaveError.conversationNotFound = error {
320-
return true
373+
return .membershipEnded
321374
}
322375
let description = String(describing: error)
323-
let benignFragments = [
324-
"cannot leave a group that has only one member",
376+
if description.contains("cannot leave a group that has only one member") {
377+
return .stillMember
378+
}
379+
let membershipEndedFragments = [
325380
"only a member of the group can send a leave request",
326381
"inbox ID already exists in the pending leave list",
327382
"NotFound::MlsGroup",
328383
]
329-
return benignFragments.contains { description.contains($0) }
384+
if membershipEndedFragments.contains(where: { description.contains($0) }) {
385+
return .membershipEnded
386+
}
387+
return nil
330388
}
331389
}

0 commit comments

Comments
 (0)