From 6f564c7d2f9d86a86864f929fe42cd5cec71d60e Mon Sep 17 00:00:00 2001 From: jevb Date: Sun, 15 Mar 2026 12:18:19 +0100 Subject: [PATCH] fix: add JSON tags to Role/VoiceState, fix WebSocket error surfacing Root cause: server's db.Role and db.VoiceState structs had no JSON tags, causing Go to serialize field names as PascalCase while the C# client expected snake_case. Every role deserialized with Id=0, crashing ToDictionary with "duplicate key: 0". - Add json tags to Role and VoiceState in Server/db/models.go - Change Disconnected event to carry reason string for diagnostics - Wire ErrorReceived in MainViewModel to show server-side WS errors - Fix MainWindow to surface WebSocket errors on MainPage (not ConnectPage) - Use _reconnectCts.Token for receive loop instead of caller's token - Make ToDictionary calls safe with TryAdd to prevent future crashes --- .../Services/ChatServiceTests.cs | 6 ++-- Client/OwnCord.Client/MainWindow.xaml.cs | 19 +++++++++++-- Client/OwnCord.Client/Services/ChatService.cs | 11 +++++--- .../Services/IWebSocketService.cs | 4 +-- .../Services/WebSocketService.cs | 9 +++--- .../ViewModels/MainViewModel.cs | 16 +++++++++-- Server/db/models.go | 28 +++++++++---------- 7 files changed, 61 insertions(+), 32 deletions(-) diff --git a/Client/OwnCord.Client.Tests/Services/ChatServiceTests.cs b/Client/OwnCord.Client.Tests/Services/ChatServiceTests.cs index af8d2de3..3e9b35fe 100644 --- a/Client/OwnCord.Client.Tests/Services/ChatServiceTests.cs +++ b/Client/OwnCord.Client.Tests/Services/ChatServiceTests.cs @@ -62,7 +62,7 @@ public class FakeWebSocketService : IWebSocketService public WebSocketState State { get; set; } = WebSocketState.None; public event Action? MessageReceived; - public event Action? Disconnected; + public event Action? Disconnected; public string? LastConnectUri { get; private set; } public string? LastConnectToken { get; private set; } @@ -104,11 +104,11 @@ public class FakeWebSocketService : IWebSocketService // Test helpers to simulate server messages public void SimulateMessage(string json) => MessageReceived?.Invoke(json); - public void SimulateDisconnect() + public void SimulateDisconnect(string reason = "test disconnect") { IsConnected = false; State = WebSocketState.Closed; - Disconnected?.Invoke(); + Disconnected?.Invoke(reason); } } diff --git a/Client/OwnCord.Client/MainWindow.xaml.cs b/Client/OwnCord.Client/MainWindow.xaml.cs index 5f11eabe..a346f786 100644 --- a/Client/OwnCord.Client/MainWindow.xaml.cs +++ b/Client/OwnCord.Client/MainWindow.xaml.cs @@ -51,7 +51,15 @@ public partial class MainWindow : Window _mainVm.Initialize(_chat); RootFrame.Navigate(new MainPage(_mainVm)); - await _chat.ConnectWebSocketAsync(host, _chat.CurrentToken!); + try + { + await _chat.ConnectWebSocketAsync(host, _chat.CurrentToken!); + } + catch (Exception wsEx) + { + // WebSocket errors after navigation should show on MainPage, not ConnectPage + _mainVm.ConnectionStatus = $"WebSocket failed: {wsEx.Message}"; + } } catch (ApiException ex) { @@ -83,7 +91,14 @@ public partial class MainWindow : Window _mainVm.Initialize(_chat); RootFrame.Navigate(new MainPage(_mainVm)); - await _chat.ConnectWebSocketAsync(host, _chat.CurrentToken!); + try + { + await _chat.ConnectWebSocketAsync(host, _chat.CurrentToken!); + } + catch (Exception wsEx) + { + _mainVm.ConnectionStatus = $"WebSocket failed: {wsEx.Message}"; + } } catch (ApiException ex) { diff --git a/Client/OwnCord.Client/Services/ChatService.cs b/Client/OwnCord.Client/Services/ChatService.cs index 3bbbce40..87c41cc8 100644 --- a/Client/OwnCord.Client/Services/ChatService.cs +++ b/Client/OwnCord.Client/Services/ChatService.cs @@ -49,7 +49,7 @@ public sealed class ChatService : IChatService _ws = ws; _ws.MessageReceived += OnMessageReceived; - _ws.Disconnected += OnDisconnected; + _ws.Disconnected += reason => OnDisconnected(reason); } // ── Auth ──────────────────────────────────────────────────────────────── @@ -105,7 +105,10 @@ public sealed class ChatService : IChatService var wsUri = $"wss://{ApiClient.NormalizeHost(host)}/api/v1/ws"; await _ws.ConnectAsync(wsUri, token, ct); - _ = RunReceiveLoopWithErrorHandlingAsync(ct); + + // Use the reconnect CTS so the loop can be cancelled on logout/disconnect, + // not the caller's token which may be default/already disposed. + _ = RunReceiveLoopWithErrorHandlingAsync(_reconnectCts.Token); } private async Task RunReceiveLoopWithErrorHandlingAsync(CancellationToken ct) @@ -321,9 +324,9 @@ public sealed class ChatService : IChatService } } - private void OnDisconnected() + private void OnDisconnected(string reason) { - ConnectionLost?.Invoke("WebSocket connection lost"); + ConnectionLost?.Invoke(reason); if (!_intentionalDisconnect && _host is not null && CurrentToken is not null) _ = ReconnectAsync(); diff --git a/Client/OwnCord.Client/Services/IWebSocketService.cs b/Client/OwnCord.Client/Services/IWebSocketService.cs index 98c01e34..cd17dfdd 100644 --- a/Client/OwnCord.Client/Services/IWebSocketService.cs +++ b/Client/OwnCord.Client/Services/IWebSocketService.cs @@ -10,8 +10,8 @@ public interface IWebSocketService /// Fires for each raw JSON message received. event Action? MessageReceived; - /// Fires when the connection drops unexpectedly. - event Action? Disconnected; + /// Fires when the connection drops unexpectedly, with a reason string. + event Action? Disconnected; Task ConnectAsync(string uri, string token, CancellationToken ct = default); Task SendAsync(object message, CancellationToken ct = default); diff --git a/Client/OwnCord.Client/Services/WebSocketService.cs b/Client/OwnCord.Client/Services/WebSocketService.cs index cc9c9fa1..77983544 100644 --- a/Client/OwnCord.Client/Services/WebSocketService.cs +++ b/Client/OwnCord.Client/Services/WebSocketService.cs @@ -21,7 +21,7 @@ public sealed class WebSocketService : IWebSocketService, IDisposable public WebSocketState State => _ws?.State ?? WebSocketState.None; public event Action? MessageReceived; - public event Action? Disconnected; + public event Action? Disconnected; public async Task ConnectAsync(string uri, string token, CancellationToken ct = default) { @@ -67,7 +67,8 @@ public sealed class WebSocketService : IWebSocketService, IDisposable result = await _ws.ReceiveAsync(buf, ct); if (result.MessageType == WebSocketMessageType.Close) { - Disconnected?.Invoke(); + var desc = _ws.CloseStatusDescription ?? _ws.CloseStatus?.ToString() ?? "server closed connection"; + Disconnected?.Invoke(desc); return; } ms.Write(buf, 0, result.Count); @@ -81,9 +82,9 @@ public sealed class WebSocketService : IWebSocketService, IDisposable { // Normal shutdown via cancellation. } - catch (WebSocketException) + catch (WebSocketException ex) { - Disconnected?.Invoke(); + Disconnected?.Invoke($"WebSocket error: {ex.Message}"); } } diff --git a/Client/OwnCord.Client/ViewModels/MainViewModel.cs b/Client/OwnCord.Client/ViewModels/MainViewModel.cs index ba2b001b..13c220e6 100644 --- a/Client/OwnCord.Client/ViewModels/MainViewModel.cs +++ b/Client/OwnCord.Client/ViewModels/MainViewModel.cs @@ -92,6 +92,7 @@ public sealed class MainViewModel : ViewModelBase, IDisposable chat.ChannelUpdated += p => RunOnUI(() => OnChannelUpdated(p)); chat.ChannelDeleted += id => RunOnUI(() => OnChannelDeleted(id)); chat.ConnectionLost += r => RunOnUI(() => OnConnectionLost(r)); + chat.ErrorReceived += p => RunOnUI(() => OnWsError(p)); chat.VoiceStateReceived += p => RunOnUI(() => OnVoiceState(p)); chat.VoiceLeaveReceived += p => RunOnUI(() => OnVoiceLeave(p)); chat.VoiceSpeakersReceived += p => RunOnUI(() => OnVoiceSpeakers(p)); @@ -675,7 +676,9 @@ public sealed class MainViewModel : ViewModelBase, IDisposable private void RebuildChannelGroups() { // Preserve expanded state across rebuilds - var expandedState = ChannelGroups.ToDictionary(g => g.CategoryName ?? "", g => g.IsExpanded); + var expandedState = new Dictionary(); + foreach (var g in ChannelGroups) + expandedState.TryAdd(g.CategoryName ?? "", g.IsExpanded); ChannelGroups.Clear(); @@ -715,7 +718,9 @@ public sealed class MainViewModel : ViewModelBase, IDisposable { MemberGroups.Clear(); - var roleMap = Roles.ToDictionary(r => r.Id); + var roleMap = new Dictionary(); + foreach (var r in Roles) + roleMap.TryAdd(r.Id, r); var grouped = Members .GroupBy(m => m.RoleId) @@ -994,7 +999,12 @@ public sealed class MainViewModel : ViewModelBase, IDisposable private void OnConnectionLost(string reason) { - ConnectionStatus = "Disconnected \u2014 reconnecting..."; + ConnectionStatus = $"Disconnected \u2014 {reason}"; + } + + private void OnWsError(WsErrorPayload error) + { + ConnectionStatus = $"Server error: {error.Message}"; } // ── Voice event handlers ───────────────────────────────────────────────── diff --git a/Server/db/models.go b/Server/db/models.go index 3e68422e..7fa3fd71 100644 --- a/Server/db/models.go +++ b/Server/db/models.go @@ -44,12 +44,12 @@ type Invite struct { // Role represents a row in the roles table. type Role struct { - ID int64 - Name string - Color *string - Permissions int64 - Position int - IsDefault bool + ID int64 `json:"id"` + Name string `json:"name"` + Color *string `json:"color"` + Permissions int64 `json:"permissions"` + Position int `json:"position"` + IsDefault bool `json:"is_default"` } // Channel represents a row in the channels table. @@ -109,14 +109,14 @@ type MessageSearchResult struct { // VoiceState represents a row in the voice_states table. // It tracks which voice channel a user is in and their current audio state. type VoiceState struct { - UserID int64 - ChannelID int64 - Username string - Muted bool - Deafened bool - Speaking bool - Camera bool - Screenshare bool + UserID int64 `json:"user_id"` + ChannelID int64 `json:"channel_id"` + Username string `json:"username"` + Muted bool `json:"muted"` + Deafened bool `json:"deafened"` + Speaking bool `json:"speaking"` + Camera bool `json:"camera"` + Screenshare bool `json:"screenshare"` } // ServerStats contains aggregate counts for the admin dashboard.