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)