From 3f4e7f379de77ff9586975da0bb36a77c1096ff5 Mon Sep 17 00:00:00 2001
From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
Date: Fri, 6 Mar 2026 15:24:55 +0000
Subject: [PATCH] core, ui: group members permanent connection errors (#6662)
---
.../Views/Chat/Group/GroupChatInfoView.swift | 4 +-
.../Chat/Group/GroupMemberInfoView.swift | 6 +
.../Views/Chat/Group/MemberSupportView.swift | 4 +-
apps/ios/SimpleXChat/ChatTypes.swift | 25 +-
.../chat/simplex/common/model/ChatModel.kt | 42 ++-
.../views/chat/group/GroupChatInfoView.kt | 6 +-
.../views/chat/group/GroupMemberInfoView.kt | 8 +
.../views/chat/group/MemberSupportView.kt | 4 +-
.../commonMain/resources/MR/base/strings.xml | 2 +
bots/api/TYPES.md | 38 ++-
bots/src/API/Docs/Types.hs | 2 +-
.../types/typescript/src/types.ts | 72 +++-
plans/2026-03-05-members-conn-errors.md | 316 ++++++++++++++++++
src/Simplex/Chat/Library/Internal.hs | 2 +-
src/Simplex/Chat/Library/Subscriber.hs | 11 +-
src/Simplex/Chat/Store/Connections.hs | 3 +
src/Simplex/Chat/Types.hs | 18 +-
17 files changed, 501 insertions(+), 62 deletions(-)
create mode 100644 plans/2026-03-05-members-conn-errors.md
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
index 257d5aac93..4113b75d0a 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
@@ -455,7 +455,9 @@ struct GroupChatInfoView: View {
}
private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey {
- if member.activeConn?.connDisabled ?? false {
+ if case .failed = member.activeConn?.connStatus {
+ return "failed"
+ } else if member.activeConn?.connDisabled ?? false {
return "disabled"
} else if member.activeConn?.connInactive ?? false {
return "inactive"
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
index 17a05ffca4..135efae74f 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
@@ -189,6 +189,12 @@ struct GroupMemberInfoView: View {
}
}
+ if let connFailedErr = member.activeConn?.connFailedErr {
+ Section {
+ infoRow("Connection failed", connFailedErr)
+ }
+ }
+
if groupInfo.membership.memberRole >= .moderator {
adminDestructiveSection(member)
} else {
diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift
index 75a6840c4e..3dc27c08f6 100644
--- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift
@@ -196,7 +196,9 @@ struct MemberSupportView: View {
}
private func memberStatus(_ member: GroupMember) -> LocalizedStringKey {
- if member.activeConn?.connDisabled ?? false {
+ if case .failed = member.activeConn?.connStatus {
+ return "failed"
+ } else if member.activeConn?.connDisabled ?? false {
return "disabled"
} else if member.activeConn?.connInactive ?? false {
return "inactive"
diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift
index c0b15666d2..b2a9611593 100644
--- a/apps/ios/SimpleXChat/ChatTypes.swift
+++ b/apps/ios/SimpleXChat/ChatTypes.swift
@@ -2113,6 +2113,11 @@ public struct Connection: Decodable, Hashable {
public var id: ChatId { get { ":\(connId)" } }
+ public var connFailedErr: String? {
+ if case let .failed(err) = connStatus { return err }
+ return nil
+ }
+
public var connDisabled: Bool {
authErrCounter >= 10 // authErrDisableCount in core
}
@@ -2298,15 +2303,16 @@ public struct PendingContactConnection: Decodable, NamedChat, Hashable {
}
}
-public enum ConnStatus: String, Decodable, Hashable {
- case new = "new"
- case prepared = "prepared"
- case joined = "joined"
- case requested = "requested"
- case accepted = "accepted"
- case sndReady = "snd-ready"
- case ready = "ready"
- case deleted = "deleted"
+public enum ConnStatus: Decodable, Hashable {
+ case new
+ case prepared
+ case joined
+ case requested
+ case accepted
+ case sndReady
+ case ready
+ case deleted
+ case failed(connError: String)
var initiated: Bool? {
get {
@@ -2319,6 +2325,7 @@ public enum ConnStatus: String, Decodable, Hashable {
case .sndReady: return nil
case .ready: return nil
case .deleted: return nil
+ case .failed: return nil
}
}
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
index 01f8197beb..668e18cf6c 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
@@ -1904,6 +1904,12 @@ data class Connection(
val connInactive: Boolean
get() = quotaErrCounter >= 5 // quotaErrInactiveCount in core
+ val connFailedErr: String?
+ get() = when (connStatus) {
+ is ConnStatus.Failed -> connStatus.connError
+ else -> null
+ }
+
val connPQEnabled: Boolean
get() = pqSndEnabled == true && pqRcvEnabled == true
@@ -2638,25 +2644,27 @@ class PendingContactConnection(
}
@Serializable
-enum class ConnStatus {
- @SerialName("new") New,
- @SerialName("prepared") Prepared,
- @SerialName("joined") Joined,
- @SerialName("requested") Requested,
- @SerialName("accepted") Accepted,
- @SerialName("snd-ready") SndReady,
- @SerialName("ready") Ready,
- @SerialName("deleted") Deleted;
+sealed class ConnStatus {
+ @Serializable @SerialName("new") object New: ConnStatus()
+ @Serializable @SerialName("prepared") object Prepared: ConnStatus()
+ @Serializable @SerialName("joined") object Joined: ConnStatus()
+ @Serializable @SerialName("requested") object Requested: ConnStatus()
+ @Serializable @SerialName("accepted") object Accepted: ConnStatus()
+ @Serializable @SerialName("sndReady") object SndReady: ConnStatus()
+ @Serializable @SerialName("ready") object Ready: ConnStatus()
+ @Serializable @SerialName("deleted") object Deleted: ConnStatus()
+ @Serializable @SerialName("failed") class Failed(val connError: String): ConnStatus()
val initiated: Boolean? get() = when (this) {
- New -> true
- Prepared -> false
- Joined -> false
- Requested -> true
- Accepted -> true
- SndReady -> null
- Ready -> null
- Deleted -> null
+ is New -> true
+ is Prepared -> false
+ is Joined -> false
+ is Requested -> true
+ is Accepted -> true
+ is SndReady -> null
+ is Ready -> null
+ is Deleted -> null
+ is Failed -> null
}
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt
index 3f80361249..dd3374d50b 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt
@@ -878,9 +878,11 @@ fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = tr
}
fun memberConnStatus(): String {
- return if (member.activeConn?.connDisabled == true) {
- generalGetString(MR.strings.member_info_member_disabled)
+ return if (member.activeConn?.connStatus is ConnStatus.Failed) {
+ generalGetString(MR.strings.member_info_member_failed)
} else if (member.activeConn?.connDisabled == true) {
+ generalGetString(MR.strings.member_info_member_disabled)
+ } else if (member.activeConn?.connInactive == true) {
generalGetString(MR.strings.member_info_member_inactive)
} else {
member.memberStatus.shortText
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt
index f09d2f44bb..8902a0fd9e 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt
@@ -562,6 +562,14 @@ fun GroupMemberInfoLayout(
}
}
+ val connFailedErr = member.activeConn?.connFailedErr
+ if (connFailedErr != null) {
+ SectionDividerSpaced()
+ SectionView {
+ InfoRow(stringResource(MR.strings.info_row_connection_failed), connFailedErr)
+ }
+ }
+
if (groupInfo.membership.memberRole >= GroupMemberRole.Moderator) {
ModeratorDestructiveSection()
} else {
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt
index e696128288..c3cf954ab6 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt
@@ -162,7 +162,9 @@ private fun ModalData.MemberSupportViewLayout(
@Composable
fun SupportChatRow(member: GroupMember) {
fun memberStatus(): String {
- return if (member.activeConn?.connDisabled == true) {
+ return if (member.activeConn?.connStatus is ConnStatus.Failed) {
+ generalGetString(MR.strings.member_info_member_failed)
+ } else if (member.activeConn?.connDisabled == true) {
generalGetString(MR.strings.member_info_member_disabled)
} else if (member.activeConn?.connInactive == true) {
generalGetString(MR.strings.member_info_member_inactive)
diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
index 4d71073dac..8b1eb44249 100644
--- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
+++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
@@ -1906,6 +1906,7 @@
Blocked by admin
blocked
disabled
+ failed
inactive
MEMBER
Role
@@ -1924,6 +1925,7 @@
Group
Chat
Connection
+ Connection failed
direct
indirect (%1$s)
Message queue info
diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md
index a66f1b379e..5dcfe81831 100644
--- a/bots/api/TYPES.md
+++ b/bots/api/TYPES.md
@@ -1473,15 +1473,35 @@ LARGE:
## ConnStatus
-**Enum type**:
-- "new"
-- "prepared"
-- "joined"
-- "requested"
-- "accepted"
-- "snd-ready"
-- "ready"
-- "deleted"
+**Discriminated union type**:
+
+New:
+- type: "new"
+
+Prepared:
+- type: "prepared"
+
+Joined:
+- type: "joined"
+
+Requested:
+- type: "requested"
+
+Accepted:
+- type: "accepted"
+
+SndReady:
+- type: "sndReady"
+
+Ready:
+- type: "ready"
+
+Deleted:
+- type: "deleted"
+
+Failed:
+- type: "failed"
+- connError: string
---
diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs
index 73ad90e91b..21970ce419 100644
--- a/bots/src/API/Docs/Types.hs
+++ b/bots/src/API/Docs/Types.hs
@@ -240,7 +240,7 @@ chatTypesDocsData =
(sti @ConnectionErrorType, STUnion, "", [], "", ""),
(sti @ConnectionMode, (STEnum' $ take 3 . consLower "CM"), "", [], "", ""),
(sti @ConnectionPlan, STUnion, "CP", [], "", ""),
- (sti @ConnStatus, (STEnum' $ consSep "Conn" '-'), "", [], "", ""),
+ (sti @ConnStatus, STUnion, "Conn", [], "", ""),
(sti @ConnType, (STEnum' $ consSep "Conn" '_'), "", [], "", ""),
(sti @Contact, STRecord, "", [], "", ""),
(sti @ContactAddressPlan, STUnion, "CAP", [], "", ""),
diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts
index bd03d7d72b..65ce90f647 100644
--- a/packages/simplex-chat-client/types/typescript/src/types.ts
+++ b/packages/simplex-chat-client/types/typescript/src/types.ts
@@ -1695,15 +1695,69 @@ export interface ComposedMessage {
mentions: {[key: string]: number} // string : int64
}
-export enum ConnStatus {
- New = "new",
- Prepared = "prepared",
- Joined = "joined",
- Requested = "requested",
- Accepted = "accepted",
- Snd_ready = "snd-ready",
- Ready = "ready",
- Deleted = "deleted",
+export type ConnStatus =
+ | ConnStatus.New
+ | ConnStatus.Prepared
+ | ConnStatus.Joined
+ | ConnStatus.Requested
+ | ConnStatus.Accepted
+ | ConnStatus.SndReady
+ | ConnStatus.Ready
+ | ConnStatus.Deleted
+ | ConnStatus.Failed
+
+export namespace ConnStatus {
+ export type Tag =
+ | "new"
+ | "prepared"
+ | "joined"
+ | "requested"
+ | "accepted"
+ | "sndReady"
+ | "ready"
+ | "deleted"
+ | "failed"
+
+ interface Interface {
+ type: Tag
+ }
+
+ export interface New extends Interface {
+ type: "new"
+ }
+
+ export interface Prepared extends Interface {
+ type: "prepared"
+ }
+
+ export interface Joined extends Interface {
+ type: "joined"
+ }
+
+ export interface Requested extends Interface {
+ type: "requested"
+ }
+
+ export interface Accepted extends Interface {
+ type: "accepted"
+ }
+
+ export interface SndReady extends Interface {
+ type: "sndReady"
+ }
+
+ export interface Ready extends Interface {
+ type: "ready"
+ }
+
+ export interface Deleted extends Interface {
+ type: "deleted"
+ }
+
+ export interface Failed extends Interface {
+ type: "failed"
+ connError: string
+ }
}
export enum ConnType {
diff --git a/plans/2026-03-05-members-conn-errors.md b/plans/2026-03-05-members-conn-errors.md
new file mode 100644
index 0000000000..b63923771a
--- /dev/null
+++ b/plans/2026-03-05-members-conn-errors.md
@@ -0,0 +1,316 @@
+# Save Permanent Connection Errors for Group Members
+
+## Context
+
+When a group member's connection handshake fails with a permanent error (e.g., `CONN NOT_ACCEPTED`, `SMP AUTH`, `AGENT A_VERSION`), the ERR event is logged to the UI event stream and discarded. The member record stays stuck in a "connecting" `GroupMemberStatus` (like `memIntroduced`, `memAccepted`) forever. Users see perpetual "connecting" with no explanation and no way to know whether to wait or re-invite.
+
+**Root cause**: `agentMsgConnStatus` (Subscriber.hs:376) only maps success events (`CONF`, `INFO`, `JOINED`, `CON`) to status transitions. The ERR handler for group members (Subscriber.hs:1054-1056) only logs to UI and completes the command — no status or error is persisted.
+
+## Solution Summary
+
+Add `ConnError {connError :: Text}` constructor to `ConnStatus`. Error text is encoded in the `conn_status TEXT` column as `"error "` via `TextEncoding`, and in JSON via `sumTypeJSON` (following `GSSError`/`CIFileStatus` pattern). No new DB column, no migration. When a non-temporary ERR arrives before connection is ready, transition to `ConnError` and notify UI. Messages are not queued for errored connections.
+
+## Technical Design
+
+### Error classification
+
+Use `temporaryOrHostError` from `Simplex.Messaging.Agent.Client` (simplexmq Client.hs:1486, exported at line 60):
+- Returns `True` for NETWORK, TIMEOUT, HOST, TEVersion, INACTIVE, CRITICAL-with-restart → **do not save**
+- Returns `False` for AUTH, CONN errors, VERSION, INTERNAL, etc. → **save as permanent error**
+
+Guard: only save when connection is not `ConnReady` and not already `ConnError`. Post-handshake errors (when `connStatus == ConnReady`) are handled by existing `processConnMERR` (AUTH counters, QUOTA counters).
+
+### Data flow
+
+```
+Agent ERR event
+ → Subscriber.hs processGroupMessage ERR handler
+ → guard: connStatus is not ConnReady, not ConnError, not temporaryOrHostError
+ → DB: UPDATE connections SET conn_status = 'error '
+ → emit: CEvtGroupMemberUpdated user gInfo m m'
+ → iOS: upsertGroupMember updates model → UI re-renders
+```
+
+### DB encoding
+
+`conn_status TEXT NOT NULL` already exists. `ConnError` encodes as `"error " <> errText` using `TextEncoding` (same as `GSSError`). No migration needed — new text values are valid in the existing column.
+
+### JSON encoding
+
+Replace manual `ToJSON`/`FromJSON` instances with `$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "Conn") ''ConnStatus)`. This follows the `GroupSndStatus`/`CIFileStatus` pattern — `sumTypeJSON` is already imported in Types.hs (line 60).
+
+JSON format (platform-dependent via `sumTypeJSON`):
+- iOS: `{"error": {"connError": "SMP AUTH"}}` (ObjectWithSingleField)
+- Android/Desktop: `{"type": "error", "connError": "SMP AUTH"}` (TaggedObject)
+- Nullary cases: `{"ready": {}}` / `{"type": "ready"}` (not plain `"ready"` strings)
+
+Note: `ConnSndReady` JSON tag changes from `"snd-ready"` to `"sndReady"` (`dropPrefix "Conn"` applies `fstToLower`). This is safe — JSON is core→UI within same build. Swift auto-synthesis matches on `sndReady` case name.
+
+### Clear on recovery
+
+When CON event arrives, `agentMsgConnStatus` returns `Just ConnReady`, `updateConnStatus` overwrites `conn_status` to `"ready"`. Error is implicitly cleared — no special cleanup needed.
+
+### ConnStatus state machine update
+
+```
+Existing transitions (unchanged):
+ ConnNew → ConnRequested → ConnAccepted → ConnSndReady → ConnReady
+ ConnNew → ConnJoined → ConnSndReady → ConnReady
+ ConnPrepared → ConnJoined → ConnSndReady → ConnReady
+ Any → ConnDeleted
+
+New transitions:
+ Any pre-ready state → ConnError (on permanent ERR)
+ ConnError → ConnReady (on successful CON — recovery)
+ ConnError → ConnDeleted (on connection deletion)
+```
+
+### Pattern match safety audit
+
+Traced every ConnStatus pattern match across Haskell (10 files), Swift (6 files), Kotlin (3 files).
+
+**Must update (exhaustive matches):**
+
+| Location | Change |
+|---|---|
+| Types.hs textEncode/textDecode (~1703) | Add ConnError encoding/decoding |
+| Types.hs ToJSON/FromJSON (~1696) | Replace with `sumTypeJSON` TH splice |
+| Swift ConnStatus.initiated | Add `case .error: return nil` |
+| Kotlin ConnStatus.initiated | Add `Error -> null` (follow-up) |
+
+**Must update (behavioral):**
+
+| Location | Current behavior | Fix |
+|---|---|---|
+| Internal.hs memberSendAction (line 2041) | ConnError falls to `otherwise -> pendingOrForwarded` — messages queued for permanently errored connections | Add pattern guard `ConnError {} <- connStatus -> Nothing` |
+
+**Verified safe — no changes needed:**
+
+| Pattern | Sites | Why safe |
+|---|---|---|
+| `== ConnReady` / `== ConnSndReady` | 12 sites (connReady, Contact.ready, GroupMember.ready, sndReady, readyMemberConn, xftpSndFileTransfer) | ConnError ≠ these → excluded from "ready" paths |
+| `== ConnPrepared` | 8 sites (joinPreparedConn, nextConnectPrepared, isContactCard, contactRequestPlan) | ConnError ≠ ConnPrepared → doesn't trigger join/prepare logic |
+| `== ConnNew` | 4 sites (contactConnInitiated, nextAcceptContactRequest, APIPrepareContact) | ConnError ≠ ConnNew → doesn't trigger new-connection logic |
+| `!= ConnDeleted` (DB WHERE) | 6 sites (getConnectionEntity, *ConnsToSub) | ConnError ≠ ConnDeleted → errored connections remain findable and subscribable (correct — enables recovery via CON). **Add TODO comments** at each site to consider whether ConnError connections should be excluded. |
+| `updateConnectionStatusFromTo` | 3 sites | Compares current to specific `fromStatus` — ConnError won't accidentally match |
+| `readyMemberConn` (Internal.hs:2078) | 1 site | `connStatus == ConnReady \|\| == ConnSndReady` — ConnError → `otherwise = Nothing` (correct) |
+| `connDisabled`/`connInactive` | 6 sites | Derived from error counters, not connStatus |
+| `agentMsgConnStatus` | 1 site | Only produces ConnSndReady/ConnRequested/ConnReady — no ConnError output |
+
+## Implementation Plan
+
+### 1. Haskell: ConnStatus type
+
+**File: `src/Simplex/Chat/Types.hs`**
+
+**ConnStatus** (~line 1673): Add constructor after `ConnDeleted`:
+```haskell
+ | ConnError {connError :: Text}
+```
+Record syntax for `sumTypeJSON` field name in JSON. `deriving (Eq, Show, Read)` unchanged.
+
+**TextEncoding instance** (~line 1703) — for DB storage:
+```haskell
+ textEncode = \case
+ ...
+ ConnError err -> "error " <> err
+ textDecode s
+ | Just err <- T.stripPrefix "error " s = Just (ConnError err)
+ | otherwise = case s of
+ "new" -> Just ConnNew
+ ... (existing cases unchanged)
+ _ -> Nothing
+```
+
+Note: `textDecode` changes from `\case` to named parameter `s` to support `stripPrefix` guard.
+
+**JSON instances** (~lines 1696-1701): Replace manual instances with TH splice:
+```haskell
+-- Remove:
+-- instance FromJSON ConnStatus where parseJSON = textParseJSON "ConnStatus"
+-- instance ToJSON ConnStatus where toJSON = J.String . textEncode; toEncoding = JE.text . textEncode
+-- Add:
+$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "Conn") ''ConnStatus)
+```
+
+`sumTypeJSON` and `dropPrefix` already imported (line 60). `FromField`/`ToField` instances unchanged — still use `TextEncoding` for DB.
+
+**`connReady`** (line 1597): No change — `== ConnReady || == ConnSndReady`, `ConnError _` naturally returns `False`.
+
+### 2. Haskell: Subscriber.hs — save error on permanent ERR
+
+**File: `src/Simplex/Chat/Library/Subscriber.hs`**
+
+Extend existing import (line 74):
+```haskell
+import Simplex.Messaging.Agent.Client (temporaryOrHostError, getAgentWorker, ...)
+```
+
+Update ERR handler in `processGroupMessage` (line 1054-1056). Current:
+```haskell
+ERR err -> do
+ eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity)
+ when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure ()
+```
+
+New:
+```haskell
+ERR err -> do
+ eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity)
+ when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure ()
+ let Connection {connStatus = cs} = conn
+ case cs of
+ ConnReady -> pure ()
+ ConnError _ -> pure ()
+ _ | temporaryOrHostError err -> pure ()
+ | otherwise -> do
+ let errText = tshow err
+ withStore' $ \db -> updateConnectionStatus db conn (ConnError errText)
+ let conn' = conn {connStatus = ConnError errText}
+ m' = m {activeConn = Just conn'}
+ toView $ CEvtGroupMemberUpdated user gInfo m m'
+```
+
+Note: `let Connection {connStatus = cs} = conn` destructures via pattern binding, avoiding ambiguous `connStatus conn` field selector under `DuplicateRecordFields`.
+
+No new store function — reuses existing `updateConnectionStatus` (Direct.hs:937) which calls `updateConnectionStatus_` → `textEncode` → stores `"error SMP AUTH"` in `conn_status`.
+
+### 3. Haskell: memberSendAction — don't queue for errored connections
+
+**File: `src/Simplex/Chat/Library/Internal.hs`**
+
+Update `memberSendAction` (line 2040-2044). Current:
+```haskell
+ Just conn@Connection {connStatus}
+ | connDisabled conn || connStatus == ConnDeleted || memberStatus == GSMemRejected -> Nothing
+ | connInactive conn -> Just MSAPending
+ | connStatus == ConnSndReady || connStatus == ConnReady -> sendBatchedOrSeparate conn
+ | otherwise -> pendingOrForwarded
+```
+
+Add pattern guard after first guard (can't use `==` with associated data):
+```haskell
+ Just conn@Connection {connStatus}
+ | connDisabled conn || connStatus == ConnDeleted || memberStatus == GSMemRejected -> Nothing
+ | ConnError {} <- connStatus -> Nothing
+ | connInactive conn -> Just MSAPending
+ | connStatus == ConnSndReady || connStatus == ConnReady -> sendBatchedOrSeparate conn
+ | otherwise -> pendingOrForwarded
+```
+
+### 4. Swift: ConnStatus enum
+
+**File: `apps/ios/SimpleXChat/ChatTypes.swift`** (~line 2301)
+
+Change from `String`-backed raw value enum to enum with associated value. Auto-synthesized `Decodable` handles `sumTypeJSON` format (same as `GroupSndStatus`, `CIFileStatus`):
+
+```swift
+public enum ConnStatus: Decodable, Hashable {
+ case new
+ case prepared
+ case joined
+ case requested
+ case accepted
+ case sndReady
+ case ready
+ case deleted
+ case error(connError: String)
+
+ var initiated: Bool? {
+ switch self {
+ case .new: return true
+ case .prepared: return false
+ case .joined: return false
+ case .requested: return true
+ case .accepted: return true
+ case .sndReady: return nil
+ case .ready: return nil
+ case .deleted: return nil
+ case .error: return nil
+ }
+ }
+}
+```
+
+No custom `init(from:)` needed. `Hashable`/`Equatable` auto-synthesized. Existing equality checks like `connStatus == .ready` still compile (nullary cases).
+
+### 5. Swift: Connection computed property
+
+**File: `apps/ios/SimpleXChat/ChatTypes.swift`**
+
+Add computed property to `Connection` struct (~line 2092, after `connStatus`):
+```swift
+public var connError: String? {
+ if case let .error(err) = connStatus { return err }
+ return nil
+}
+```
+
+### 6. Swift: Member list status
+
+**File: `apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift`**
+
+Update `memberConnStatus` function (~line 457). Insert error check FIRST (before `connDisabled`/`connInactive`):
+```swift
+private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey {
+ if case .error = member.activeConn?.connStatus {
+ return "connection error"
+ } else if member.activeConn?.connDisabled ?? false {
+ return "disabled"
+ } else if member.activeConn?.connInactive ?? false {
+ return "inactive"
+ } else {
+ return member.memberStatus.shortText
+ }
+}
+```
+
+**File: `apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift`**
+
+Update `memberStatus` function (line 198). Insert error check FIRST (before `connDisabled` at line 199):
+```swift
+ if case .error = member.activeConn?.connStatus {
+ return "connection error"
+ } else if member.activeConn?.connDisabled ?? false {
+```
+
+### 7. Swift: Member info error display
+
+**File: `apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift`**
+
+Add error display section after the `connStats` section (~line 190):
+```swift
+if let connError = member.activeConn?.connError {
+ Section(header: Text("Connection error").foregroundColor(theme.colors.secondary)) {
+ Text(connError)
+ .foregroundColor(theme.colors.secondary)
+ .font(.callout)
+ .textSelection(.enabled)
+ }
+}
+```
+
+## Files Changed Summary
+
+| Layer | File | Change |
+|-------|------|--------|
+| Core | `Types.hs` | Add `ConnError {connError :: Text}` to ConnStatus, update TextEncoding, replace JSON with `sumTypeJSON` TH splice |
+| Logic | `Subscriber.hs` | Import `temporaryOrHostError`, handle permanent ERR for group members |
+| Logic | `Internal.hs` | Add `ConnError` guard to `memberSendAction` → return `Nothing` |
+| iOS | `ChatTypes.swift` | ConnStatus: auto-synthesized Decodable with `.error(connError:)`, Connection: `connError` computed property |
+| iOS | `GroupChatInfoView.swift` | Show "connection error" in `memberConnStatus` (first check) |
+| iOS | `MemberSupportView.swift` | Show "connection error" in `memberStatus` (first check) |
+| iOS | `GroupMemberInfoView.swift` | Show error description section |
+
+## Verification
+
+1. **Build Haskell**: `cabal build --ghc-options -O0`
+2. **Build iOS**: Verify Swift compiles — existing `connStatus == .ready` comparisons still work (nullary cases)
+3. **JSON format**: Verify `sumTypeJSON` output matches Swift auto-synthesis expectations (nullary: `{"ready": {}}`, error: `{"error": {"connError": "..."}}`)
+4. **Backward compat**: New `"error ..."` values in `conn_status` only appear after code update. Old code cannot parse them (downgrade risk, same as any new enum value).
+5. **Recovery**: CON event → `updateConnectionStatus_ ConnReady` → overwrites `"error ..."` with `"ready"` in DB
+6. **memberSendAction**: Verify messages are NOT queued for ConnError connections
+
+## Out of Scope (immediate follow-up)
+
+**Kotlin/Android/Desktop**: `ConnStatus` enum in `ChatModel.kt:2640` needs custom serializer for `sumTypeJSON` format (TaggedObject: `{"type": "error", "connError": "..."}`) + `Connection` needs `connError` computed property + member status UI. Must be updated before Android/Desktop builds from this commit. Existing bug at `GroupChatInfoView.kt:883` (`connDisabled` checked twice, should be `connInactive` on second check).
diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs
index 00fd2f18d9..4dc6445f67 100644
--- a/src/Simplex/Chat/Library/Internal.hs
+++ b/src/Simplex/Chat/Library/Internal.hs
@@ -2038,7 +2038,7 @@ memberSendAction GroupInfo {useRelays, membership} events members m@GroupMember
| otherwise = case memberConn m of
Nothing -> pendingOrForwarded
Just conn@Connection {connStatus}
- | connDisabled conn || connStatus == ConnDeleted || memberStatus == GSMemRejected -> Nothing
+ | connDisabled conn || connStatus == ConnDeleted || isConnFailed connStatus || memberStatus == GSMemRejected -> Nothing
| connInactive conn -> Just MSAPending
| connStatus == ConnSndReady || connStatus == ConnReady -> sendBatchedOrSeparate conn
| otherwise -> pendingOrForwarded
diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs
index 7fcc07640d..4a2a515929 100644
--- a/src/Simplex/Chat/Library/Subscriber.hs
+++ b/src/Simplex/Chat/Library/Subscriber.hs
@@ -71,7 +71,7 @@ import Simplex.FileTransfer.Protocol (FilePartyI)
import qualified Simplex.FileTransfer.Transport as XFTP
import Simplex.FileTransfer.Types (FileErrorType (..), RcvFileId, SndFileId)
import Simplex.Messaging.Agent
-import Simplex.Messaging.Agent.Client (getAgentWorker, waitForWork, withWork_, withWorkItems)
+import Simplex.Messaging.Agent.Client (getAgentWorker, temporaryOrHostError, waitForWork, withWork_, withWorkItems)
import Simplex.Messaging.Agent.Env.SQLite (Worker (..))
import Simplex.Messaging.Agent.Protocol
import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..))
@@ -366,19 +366,20 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
processUserContactRequest agentMessage entity conn uc
where
updateConnStatus :: ConnectionEntity -> CM ConnectionEntity
- updateConnStatus acEntity = case agentMsgConnStatus agentMessage of
+ updateConnStatus acEntity = case agentMsgConnStatus (entityConnection acEntity) agentMessage of
Just connStatus -> do
let conn = (entityConnection acEntity) {connStatus}
withStore' $ \db -> updateConnectionStatus db conn connStatus
pure $ updateEntityConnStatus acEntity connStatus
Nothing -> pure acEntity
- agentMsgConnStatus :: AEvent e -> Maybe ConnStatus
- agentMsgConnStatus = \case
+ agentMsgConnStatus :: Connection -> AEvent e -> Maybe ConnStatus
+ agentMsgConnStatus Connection {connStatus = cs} = \case
JOINED True _ -> Just ConnSndReady
CONF {} -> Just ConnRequested
INFO {} -> Just ConnSndReady
CON _ -> Just ConnReady
+ ERR err | cs /= ConnReady && not (temporaryOrHostError err) -> Just $ ConnFailed (tshow err)
_ -> Nothing
processCONFpqSupport :: Connection -> PQSupport -> CM Connection
@@ -1054,6 +1055,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
ERR err -> do
eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity)
when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure ()
+ when (isConnFailed $ connStatus conn) $
+ toView $ CEvtGroupMemberUpdated user gInfo m m
-- TODO add debugging output
_ -> pure ()
where
diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs
index bbb5b6a8c0..3ae5e257b6 100644
--- a/src/Simplex/Chat/Store/Connections.hs
+++ b/src/Simplex/Chat/Store/Connections.hs
@@ -71,6 +71,9 @@ getChatLockEntity db agentConnId = do
ExceptT . firstRow fromOnly (SEInternalError "group member connection group_id not found") $
DB.query db "SELECT group_id FROM group_members WHERE group_member_id = ?" (Only groupMemberId)
+-- TODO consider whether ConnFailed connections should be excluded:
+-- - from receiving: getConnectionEntity, getContactConnEntityByConnReqHash
+-- - from subscribing: getContactConnsToSub, getUCLConnsToSub, getMemberConnsToSub, getPendingConnsToSub
getConnectionEntity :: DB.Connection -> VersionRangeChat -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity
getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do
c@Connection {connType, entityId} <- getConnection_
diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs
index 44ea46492c..f0145840e2 100644
--- a/src/Simplex/Chat/Types.hs
+++ b/src/Simplex/Chat/Types.hs
@@ -1687,19 +1687,14 @@ data ConnStatus
ConnReady
| -- | connection deleted
ConnDeleted
+ | -- | connection had a permanent error during handshake
+ ConnFailed {connError :: Text}
deriving (Eq, Show, Read)
instance FromField ConnStatus where fromField = fromTextField_ textDecode
instance ToField ConnStatus where toField = toField . textEncode
-instance FromJSON ConnStatus where
- parseJSON = textParseJSON "ConnStatus"
-
-instance ToJSON ConnStatus where
- toJSON = J.String . textEncode
- toEncoding = JE.text . textEncode
-
instance TextEncoding ConnStatus where
textDecode = \case
"new" -> Just ConnNew
@@ -1710,6 +1705,7 @@ instance TextEncoding ConnStatus where
"snd-ready" -> Just ConnSndReady
"ready" -> Just ConnReady
"deleted" -> Just ConnDeleted
+ s | Just err <- T.stripPrefix "failed " s -> Just (ConnFailed err)
_ -> Nothing
textEncode = \case
ConnNew -> "new"
@@ -1720,6 +1716,12 @@ instance TextEncoding ConnStatus where
ConnSndReady -> "snd-ready"
ConnReady -> "ready"
ConnDeleted -> "deleted"
+ ConnFailed err -> "failed " <> err
+
+isConnFailed :: ConnStatus -> Bool
+isConnFailed = \case
+ ConnFailed {} -> True
+ _ -> False
data ConnType = ConnContact | ConnMember | ConnUserContact
deriving (Eq, Show)
@@ -1935,6 +1937,8 @@ $(JQ.deriveJSON defaultJSON ''GroupMemberSettings)
$(JQ.deriveJSON defaultJSON ''SecurityCode)
+$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "Conn") ''ConnStatus)
+
$(JQ.deriveJSON defaultJSON ''Connection)
$(JQ.deriveJSON defaultJSON ''PendingContactConnection)