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
This commit is contained in:
jevb
2026-03-15 12:18:19 +01:00
parent b4535aeea0
commit 6f564c7d2f
7 changed files with 61 additions and 32 deletions
@@ -62,7 +62,7 @@ public class FakeWebSocketService : IWebSocketService
public WebSocketState State { get; set; } = WebSocketState.None;
public event Action<string>? MessageReceived;
public event Action? Disconnected;
public event Action<string>? 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);
}
}
+17 -2
View File
@@ -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)
{
@@ -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();
@@ -10,8 +10,8 @@ public interface IWebSocketService
/// <summary>Fires for each raw JSON message received.</summary>
event Action<string>? MessageReceived;
/// <summary>Fires when the connection drops unexpectedly.</summary>
event Action? Disconnected;
/// <summary>Fires when the connection drops unexpectedly, with a reason string.</summary>
event Action<string>? Disconnected;
Task ConnectAsync(string uri, string token, CancellationToken ct = default);
Task SendAsync(object message, CancellationToken ct = default);
@@ -21,7 +21,7 @@ public sealed class WebSocketService : IWebSocketService, IDisposable
public WebSocketState State => _ws?.State ?? WebSocketState.None;
public event Action<string>? MessageReceived;
public event Action? Disconnected;
public event Action<string>? 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}");
}
}
@@ -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<string, bool>();
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<long, WsRole>();
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 ─────────────────────────────────────────────────
+14 -14
View File
@@ -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.