Skip to content

Messages stuck in sending forever when forum topic creation fails #2054

@marinaiosdev

Description

@marinaiosdev

Messages stuck in "sending..." forever when forum topic creation fails

Description

The error handler for _internal_createForumChannelTopic() is empty in submodules/TelegramCore/Sources/State/PendingMessageManager.swift:598:

}, error: { _ in
    //TODO:release handle errors
}))

If topic creation fails due to permissions, a topic limit, or a network error, all messages targeting that topic remain stuck in .waitingForNewTopic.
They never transition to .Failed, so the user sees an infinite spinner with no way to retry.

Steps to Reproduce

  1. Open a forum-enabled channel where you don't have topic creation permissions (or where the topic limit is reached).
  2. Tap "New Topic", write a message, send it.
  3. The client fires _internal_createForumChannelTopic() → server returns an error (CHAT_ADMIN_REQUIRED, CHANNELS_TOO_MUCH, etc.).
  4. Messages are stuck.

This is also reproducible with a transient network failure during the createForumChannelTopic

Expected vs Actual

Expected: Messages transition to the failed state. The UI shows the red error indicator, and the user can retry or delete them.

Actual: Messages stay in "sending..." indefinitely.

Impact

This causes effective data loss: the user composes a message, sends it, and it disappears into permanent "sending..." limbo.

Root Cause

Three things compound here:

1) Empty error closure

The _internal_createForumChannelTopic signal can fail, and nothing happens in response.
The success path in submodules/TelegramCore/Sources/State/PendingMessageManager.swift:537 correctly moves messages to the new topic and resumes sending.
The error path at submodules/TelegramCore/Sources/State/PendingMessageManager.swift:598 does nothing.

// submodules/TelegramCore/Sources/State/PendingMessageManager.swift:527
disposable.set(_internal_createForumChannelTopic(
    postbox: strongSelf.postbox,
    network: strongSelf.network,
    // ...
).startStrict(next: { [weak strongSelf] topicId in
    // success: moves messages, resumes pipeline — works fine
}, error: { _ in
    //TODO:release handle errors   // <-- bug
}))

2) newTopicDisposables not cleaned up on error

On success, the disposable is cleaned up in submodules/TelegramCore/Sources/State/PendingMessageManager.swift:587:

strongSelf.newTopicDisposables[messagePeerId]?.dispose()
strongSelf.newTopicDisposables[messagePeerId] = nil

On error, nothing cleans it up. The guard at submodules/TelegramCore/Sources/State/PendingMessageManager.swift:518 (if strongSelf.newTopicDisposables[messagePeerId] == nil) then blocks any future retry because the key is still in the dictionary.

3) beginSendingMessages can't recover them

When the system re-triggers beginSendingMessages (for example, after a network change), the messages already have a PendingMessageContext in .waitingForNewTopic. But beginSendingMessages only picks up messages in .collectingInfo at submodules/TelegramCore/Sources/State/PendingMessageManager.swift:606. As a result, these messages are orphaned and the pipeline can no longer reach them.

Proposed Fix

Handle the error path of _internal_createForumChannelTopic() symmetrically to the success path:

  • Mark all pending messages for that peer as failed instead of leaving them in .waitingForNewTopic.
  • Clear newTopicDisposables[peerId] on error so retry is possible.
  • Ensure a later retry (resendMessages, reconnect, or a manual retry) re-enters the topic-creation flow.

One possible implementation is to replace the empty error closure with logic that collects affected messages, marks them as failed via failMessages(), notifies subscribers, and clears the disposable:

}, error: { [weak strongSelf] _ in
    guard let strongSelf else {
        return
    }

    var failMessageIds: [MessageId] = []
    for (_, messageContext) in strongSelf.messageContexts {
        if case let .waitingForNewTopic(message) = messageContext.state {
            if message.id.peerId == messagePeerId {
                failMessageIds.append(message.id)
            }
        }
    }

    if !failMessageIds.isEmpty {
        let _ = (failMessages(
            postbox: strongSelf.postbox,
            ids: failMessageIds
        )
        |> deliverOn(strongSelf.queue)).startStandalone(completed: { [weak strongSelf] in
            guard let strongSelf else {
                return
            }
            for id in failMessageIds {
                if let context = strongSelf.messageContexts[id] {
                    context.error = .flood
                    context.state = .none
                    for subscriber in context.statusSubscribers.copyItems() {
                        subscriber(nil, context.error)
                    }
                }
            }
            if let summaryContext = strongSelf.peerSummaryContexts[messagePeerId] {
                for subscriber in summaryContext.messageFailedSubscribers.copyItems() {
                    subscriber(.flood)
                }
            }
        })
    }

    strongSelf.newTopicDisposables[messagePeerId]?.dispose()
    strongSelf.newTopicDisposables[messagePeerId] = nil
}))

Ideally, a dedicated PendingMessageFailureReason.topicCreationFailed case would be better

Acceptance Criteria

  • On topic creation error, affected messages transition to .Failed instead of remaining in .waitingForNewTopic.
  • After a failure, retry or resendMessages attempts topic creation again.
  • newTopicDisposables[peerId] is cleared on error.

Testing

  • Mock _internal_createForumChannelTopic to return an error. Enqueue messages with threadId == Message.newTopicThreadId.
  • After failure, call resendMessages. Assert messages re-enter the pipeline and topic creation is attempted again.
  • After error, assert newTopicDisposables[peerId] is nil.
  • Enqueue 3+ messages for the same topic, trigger an error, and assert that all of them fail, not just the first.
  • Manual: forum channel, no permissions to create topics. Send message → should show red error indicator, retry/delete should work.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions