Skip to content

Commit bb924db

Browse files
committed
2 parents 73e261f + b1afae5 commit bb924db

2 files changed

Lines changed: 174 additions & 62 deletions

File tree

src/Sendspin.SDK/Client/SendSpinClient.cs

Lines changed: 115 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -120,12 +120,52 @@ public async Task ConnectAsync(Uri serverUri, CancellationToken cancellationToke
120120
// Connect WebSocket
121121
await _connection.ConnectAsync(serverUri, cancellationToken);
122122

123-
// Prepare handshake completion
123+
// Perform handshake (send client hello, wait for server hello)
124+
await SendHandshakeAsync(cancellationToken);
125+
}
126+
127+
/// <summary>
128+
/// Sends the ClientHello message and waits for the ServerHello response.
129+
/// Used for both initial connection and reconnection handshakes.
130+
/// </summary>
131+
private async Task SendHandshakeAsync(CancellationToken cancellationToken = default)
132+
{
124133
_handshakeTcs = new TaskCompletionSource<bool>();
125134

126-
// Send client hello with proper payload envelope
127-
// Use audio formats from capabilities (order matters - server picks first supported)
128-
var hello = ClientHelloMessage.Create(
135+
var hello = CreateClientHelloMessage();
136+
var helloJson = MessageSerializer.Serialize(hello);
137+
_logger.LogInformation("Sending client/hello:\n{Json}", helloJson);
138+
await _connection.SendMessageAsync(hello, cancellationToken);
139+
140+
// Wait for server hello with timeout
141+
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
142+
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
143+
144+
try
145+
{
146+
var registration = linkedCts.Token.Register(() => _handshakeTcs.TrySetCanceled());
147+
var success = await _handshakeTcs.Task;
148+
149+
if (success)
150+
{
151+
_logger.LogInformation("Handshake complete with server {ServerId} ({ServerName})", ServerId, ServerName);
152+
}
153+
}
154+
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
155+
{
156+
_logger.LogError("Handshake timeout - no server/hello received");
157+
await _connection.DisconnectAsync("handshake_timeout");
158+
throw new TimeoutException("Server did not respond to handshake");
159+
}
160+
}
161+
162+
/// <summary>
163+
/// Creates the ClientHello message from current capabilities.
164+
/// Extracted for reuse between initial connection and reconnection handshakes.
165+
/// </summary>
166+
private ClientHelloMessage CreateClientHelloMessage()
167+
{
168+
return ClientHelloMessage.Create(
129169
clientId: _capabilities.ClientId,
130170
name: _capabilities.ClientName,
131171
supportedRoles: _capabilities.Roles,
@@ -163,31 +203,36 @@ public async Task ConnectAsync(Uri serverUri, CancellationToken cancellationToke
163203
SoftwareVersion = _capabilities.SoftwareVersion
164204
}
165205
);
206+
}
166207

167-
// Log the full JSON being sent for debugging
168-
var helloJson = MessageSerializer.Serialize(hello);
169-
_logger.LogInformation("Sending client/hello:\n{Json}", helloJson);
170-
await _connection.SendMessageAsync(hello, cancellationToken);
171-
172-
// Wait for server hello with timeout
173-
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
174-
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
208+
/// <summary>
209+
/// Performs handshake after the connection layer has successfully reconnected the WebSocket.
210+
/// Called from OnConnectionStateChanged when entering Handshaking state during reconnection.
211+
/// </summary>
212+
/// <remarks>
213+
/// Clock synchronizer is reset in HandleServerHello when the handshake completes,
214+
/// so we don't need to reset it here.
215+
/// </remarks>
216+
private async Task PerformReconnectHandshakeAsync(CancellationToken cancellationToken = default)
217+
{
218+
_logger.LogInformation("WebSocket reconnected, performing handshake...");
175219

176220
try
177221
{
178-
var registration = linkedCts.Token.Register(() => _handshakeTcs.TrySetCanceled());
179-
var success = await _handshakeTcs.Task;
180-
181-
if (success)
182-
{
183-
_logger.LogInformation("Handshake complete with server {ServerId} ({ServerName})", ServerId, ServerName);
184-
}
222+
await SendHandshakeAsync(cancellationToken);
185223
}
186-
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
224+
catch (TimeoutException)
187225
{
188-
_logger.LogError("Handshake timeout - no server/hello received");
189-
await _connection.DisconnectAsync("handshake_timeout");
190-
throw new TimeoutException("Server did not respond to handshake");
226+
_logger.LogWarning("Reconnect handshake timed out");
227+
}
228+
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
229+
{
230+
_logger.LogDebug("Reconnect handshake cancelled");
231+
}
232+
catch (Exception ex)
233+
{
234+
_logger.LogError(ex, "Reconnect handshake failed");
235+
await _connection.DisconnectAsync("handshake_failed");
191236
}
192237
}
193238

@@ -266,15 +311,28 @@ private void OnConnectionStateChanged(object? sender, ConnectionStateChangedEven
266311
// Forward the event
267312
ConnectionStateChanged?.Invoke(this, e);
268313

269-
// Handle disconnection
314+
// Stop time sync on any disconnection-related state to prevent
315+
// "WebSocket is not connected" spam from the time sync loop
316+
if (e.NewState is ConnectionState.Disconnected or ConnectionState.Reconnecting)
317+
{
318+
StopTimeSyncLoop();
319+
}
320+
321+
// Clean up client state on full disconnection
270322
if (e.NewState == ConnectionState.Disconnected)
271323
{
272324
_handshakeTcs?.TrySetResult(false);
273-
StopTimeSyncLoop();
274325
ServerId = null;
275326
ServerName = null;
276327
ConnectionReason = null;
277328
}
329+
330+
// Re-handshake when WebSocket reconnects successfully
331+
// Use e.OldState instead of a separate field to avoid race conditions
332+
if (e.NewState == ConnectionState.Handshaking && e.OldState == ConnectionState.Reconnecting)
333+
{
334+
PerformReconnectHandshakeAsync().SafeFireAndForget(_logger);
335+
}
278336
}
279337

280338
private void OnTextMessageReceived(object? sender, string json)
@@ -407,8 +465,8 @@ private void StartTimeSyncLoop()
407465

408466
_timeSyncCts = new CancellationTokenSource();
409467

410-
// Fire-and-forget: task runs until cancelled, no need to await
411-
_ = TimeSyncLoopAsync(_timeSyncCts.Token);
468+
// Fire-and-forget with proper exception handling
469+
TimeSyncLoopAsync(_timeSyncCts.Token).SafeFireAndForget(_logger);
412470

413471
_logger.LogDebug("Time sync loop started (adaptive intervals)");
414472
}
@@ -631,7 +689,10 @@ private void HandleGroupUpdate(string json)
631689
// Create group state if needed
632690
_currentGroup ??= new GroupState();
633691

634-
// Per spec, group/update only contains: group_id, group_name, playback_state
692+
var previousGroupId = _currentGroup.GroupId;
693+
var previousName = _currentGroup.Name;
694+
695+
// group/update contains: group_id, group_name, playback_state
635696
// Volume, mute, metadata come via server/state (handled in HandleServerState)
636697
if (!string.IsNullOrEmpty(message.GroupId))
637698
_currentGroup.GroupId = message.GroupId;
@@ -640,7 +701,22 @@ private void HandleGroupUpdate(string json)
640701
if (message.PlaybackState.HasValue)
641702
_currentGroup.PlaybackState = message.PlaybackState.Value;
642703

643-
_logger.LogDebug("Group update: GroupId={GroupId}, Name={Name}, State={State}",
704+
// Log group ID changes (helps diagnose grouping issues)
705+
if (previousGroupId != _currentGroup.GroupId && !string.IsNullOrEmpty(previousGroupId))
706+
{
707+
_logger.LogInformation("group/update [{Player}]: Group ID changed {OldId} -> {NewId}",
708+
_capabilities.ClientName, previousGroupId, _currentGroup.GroupId);
709+
}
710+
711+
// Log group name changes
712+
if (previousName != _currentGroup.Name && _currentGroup.Name is not null)
713+
{
714+
_logger.LogInformation("group/update [{Player}]: Group name changed '{OldName}' -> '{NewName}'",
715+
_capabilities.ClientName, previousName ?? "(none)", _currentGroup.Name);
716+
}
717+
718+
_logger.LogDebug("group/update [{Player}]: GroupId={GroupId}, Name={Name}, State={State}",
719+
_capabilities.ClientName,
644720
_currentGroup.GroupId,
645721
_currentGroup.Name ?? "(none)",
646722
_currentGroup.PlaybackState);
@@ -701,7 +777,8 @@ private void HandleServerState(string json)
701777
_currentGroup.Muted = payload.Controller.Muted.Value;
702778
}
703779

704-
_logger.LogDebug("Server state update: Volume={Volume}, Muted={Muted}, Track={Track} by {Artist}",
780+
_logger.LogDebug("server/state [{Player}]: Volume={Volume}, Muted={Muted}, Track={Track} by {Artist}",
781+
_capabilities.ClientName,
705782
_currentGroup.Volume,
706783
_currentGroup.Muted,
707784
_currentGroup.Metadata?.Title ?? "unknown",
@@ -742,7 +819,8 @@ private void HandleServerCommand(string json)
742819
_playerState.Volume = player.Volume.Value;
743820
_audioPipeline?.SetVolume(player.Volume.Value);
744821
changed = true;
745-
_logger.LogInformation("server/command: Applied volume {Volume}", player.Volume.Value);
822+
_logger.LogInformation("server/command [{Player}]: Applied volume {Volume}",
823+
_capabilities.ClientName, player.Volume.Value);
746824
}
747825

748826
// Apply mute change - update player state and audio pipeline
@@ -751,36 +829,27 @@ private void HandleServerCommand(string json)
751829
_playerState.Muted = player.Mute.Value;
752830
_audioPipeline?.SetMuted(player.Mute.Value);
753831
changed = true;
754-
_logger.LogInformation("server/command: Applied mute {Muted}", player.Mute.Value);
832+
_logger.LogInformation("server/command [{Player}]: Applied mute {Muted}",
833+
_capabilities.ClientName, player.Mute.Value);
755834
}
756835

757836
if (changed)
758837
{
759838
// Notify listeners of player state change
760839
PlayerStateChanged?.Invoke(this, _playerState);
761840

762-
// Send acknowledgement back to server per spec
763-
// "State updates must be sent whenever any state changes, including changes
764-
// triggered by server/command or device controls"
841+
// ACK: send client/state to confirm applied state back to server.
765842
SendPlayerStateAckAsync().SafeFireAndForget(_logger);
766843
}
767844
}
768845

769846
/// <summary>
770-
/// Sends a player state acknowledgement after a server/command.
847+
/// Sends a client/state acknowledgement after applying a server/command.
848+
/// Reports current player volume and mute state back to the server.
771849
/// </summary>
772850
private async Task SendPlayerStateAckAsync()
773851
{
774-
try
775-
{
776-
await SendPlayerStateAsync(_playerState.Volume, _playerState.Muted);
777-
_logger.LogDebug("Sent player state ACK: Volume={Volume}, Muted={Muted}",
778-
_playerState.Volume, _playerState.Muted);
779-
}
780-
catch (Exception ex)
781-
{
782-
_logger.LogWarning(ex, "Failed to send player state ACK");
783-
}
852+
await SendPlayerStateAsync(_playerState.Volume, _playerState.Muted);
784853
}
785854

786855
/// <summary>

src/Sendspin.SDK/Connection/SendSpinConnection.cs

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ public sealed class SendspinConnection : ISendspinConnection
2323
private Uri? _serverUri;
2424
private ConnectionState _state = ConnectionState.Disconnected;
2525
private int _reconnectAttempt;
26+
private int _connectionLostGuard;
2627
private bool _disposed;
2728

2829
public ConnectionState State => _state;
@@ -50,7 +51,28 @@ public async Task ConnectAsync(Uri serverUri, CancellationToken cancellationToke
5051
_serverUri = serverUri;
5152
_reconnectAttempt = 0;
5253

53-
await ConnectInternalAsync(cancellationToken);
54+
try
55+
{
56+
await ConnectInternalAsync(cancellationToken);
57+
}
58+
catch (OperationCanceledException)
59+
{
60+
throw;
61+
}
62+
catch (Exception)
63+
{
64+
if (_options.AutoReconnect && !cancellationToken.IsCancellationRequested)
65+
{
66+
// Initial connection failed - enter reconnection loop
67+
SetState(ConnectionState.Reconnecting, "Initial connection failed");
68+
await TryReconnectAsync(cancellationToken);
69+
}
70+
else
71+
{
72+
SetState(ConnectionState.Disconnected, "Connection failed");
73+
throw;
74+
}
75+
}
5476
}
5577

5678
private async Task ConnectInternalAsync(CancellationToken cancellationToken)
@@ -91,16 +113,10 @@ private async Task ConnectInternalAsync(CancellationToken cancellationToken)
91113
catch (Exception ex)
92114
{
93115
_logger.LogError(ex, "Failed to connect to {Uri}", _serverUri);
94-
SetState(ConnectionState.Disconnected, ex.Message, ex);
95-
96-
if (_options.AutoReconnect && !cancellationToken.IsCancellationRequested)
97-
{
98-
await TryReconnectAsync(cancellationToken);
99-
}
100-
else
101-
{
102-
throw;
103-
}
116+
// Let caller decide how to handle failure.
117+
// TryReconnectAsync's loop handles retries without transitioning through Disconnected.
118+
// ConnectAsync handles initial connection failure.
119+
throw;
104120
}
105121
}
106122

@@ -145,6 +161,13 @@ public async Task SendMessageAsync<T>(T message, CancellationToken cancellationT
145161

146162
if (_webSocket?.State != WebSocketState.Open)
147163
{
164+
// Trigger connection lost handling if receive loop hasn't detected it yet.
165+
// This handles the race where WebSocket detected closure but ReceiveAsync is still blocking.
166+
if (_state is ConnectionState.Connected or ConnectionState.Handshaking)
167+
{
168+
_ = Task.Run(() => HandleConnectionLostAsync());
169+
}
170+
148171
throw new InvalidOperationException("WebSocket is not connected");
149172
}
150173

@@ -173,6 +196,11 @@ public async Task SendBinaryAsync(ReadOnlyMemory<byte> data, CancellationToken c
173196

174197
if (_webSocket?.State != WebSocketState.Open)
175198
{
199+
if (_state is ConnectionState.Connected or ConnectionState.Handshaking)
200+
{
201+
_ = Task.Run(() => HandleConnectionLostAsync());
202+
}
203+
176204
throw new InvalidOperationException("WebSocket is not connected");
177205
}
178206

@@ -261,15 +289,30 @@ private async Task HandleConnectionLostAsync()
261289
if (_state == ConnectionState.Disconnecting || _disposed)
262290
return;
263291

264-
SetState(ConnectionState.Reconnecting, "Connection lost");
292+
// Atomic guard - only the first caller proceeds, prevents duplicate reconnection attempts
293+
// when both send failure and receive loop detect connection loss simultaneously
294+
if (Interlocked.CompareExchange(ref _connectionLostGuard, 1, 0) == 1)
295+
{
296+
_logger.LogDebug("Connection loss already being handled, skipping duplicate call");
297+
return;
298+
}
265299

266-
if (_options.AutoReconnect)
300+
try
267301
{
268-
await TryReconnectAsync(CancellationToken.None);
302+
SetState(ConnectionState.Reconnecting, "Connection lost");
303+
304+
if (_options.AutoReconnect)
305+
{
306+
await TryReconnectAsync(CancellationToken.None);
307+
}
308+
else
309+
{
310+
SetState(ConnectionState.Disconnected, "Connection lost");
311+
}
269312
}
270-
else
313+
finally
271314
{
272-
SetState(ConnectionState.Disconnected, "Connection lost");
315+
Interlocked.Exchange(ref _connectionLostGuard, 0);
273316
}
274317
}
275318

0 commit comments

Comments
 (0)