Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions ObsWebSocket.Core/ObsWebSocketClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -311,14 +311,21 @@ await currentInitialConnectionTcs
catch (Exception ex) // Catches exceptions set on the TCS or cancellation
{
_logger.LogConnectasyncFailedToEstablishInitialConnection(ex);
// Ensure finalization happens if the TCS faulted, loop might still be running/cleaning up
if (

if (currentInitialConnectionTcs.Task.IsFaulted && loopTask is not null)
{
// The loop gave up and is finalizing on its own. Finalizing here as well raced it,
// and whichever got there first decided the reason Disconnected reported. Waiting
// also means Disconnected has been raised by the time this throws.
await loopTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
}
else if (
_connectionState
is not ConnectionState.Disconnected
and not ConnectionState.Disconnecting
)
{
// Don't wait indefinitely, trigger finalize and let it run
// Timed out or cancelled while the loop may still be retrying: stop it here.
_ = FinalizeDisconnectionAsync(
WebSocketCloseStatus.InternalServerError,
"Initial connection failed.",
Expand Down
65 changes: 65 additions & 0 deletions ObsWebSocket.Tests/ClientMessageHandlingTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ObsWebSocket.Core;
using ObsWebSocket.Core.Networking;
using ObsWebSocket.Core.Protocol.Generated;
using ObsWebSocket.Core.Protocol.Responses;
using ObsWebSocket.Tests.Fakes;
Expand Down Expand Up @@ -323,6 +326,68 @@ public async Task ConnectionLoop_LostWithAutoReconnectOff_StaysDisconnected()
Assert.IsFalse(fake.Client.IsConnected);
}

/// <summary>Holds the connection loop at the point it gives up, to force the race below.</summary>
private sealed class StallOnGiveUp : ILoggerProvider, ILogger
{
public ILogger CreateLogger(string categoryName) => this;

public IDisposable? BeginScope<TState>(TState state)
where TState : notnull => null;

public bool IsEnabled(LogLevel logLevel) => true;

public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter
)
{
// 26: the loop has given up and is about to report why.
if (eventId.Id == 26)
{
Thread.Sleep(300);
}
}

public void Dispose() { }
}

[TestMethod]
[Timeout(TestTimeout)]
public async Task ConnectAsync_FirstAttemptFailsWhileLoopIsSlow_ReportsTheLoopsReasonOnce()
{
ServiceCollection services = new();
_ = services.AddLogging(builder =>
builder.ClearProviders().AddProvider(new StallOnGiveUp())
);
_ = services.AddObsWebSocketClient(options =>
{
options.ServerUri = new Uri("ws://fake-obs:4455");
options.AutoReconnectEnabled = false;
});
_ = services.AddSingleton<IWebSocketConnectionFactory>(
new FakeObsServer { RefuseConnections = true }
);
await using ServiceProvider provider = services.BuildServiceProvider();
ObsWebSocketClient client = provider.GetRequiredService<ObsWebSocketClient>();

List<Exception?> reasons = [];
client.Disconnected += (_, e) => reasons.Add(e.ReasonException);

_ = await Assert.ThrowsExactlyAsync<ConnectionAttemptFailedException>(() =>
client.ConnectAsync()
);

// Disconnected has fired by the time ConnectAsync throws, once, with the loop's account
// of how many attempts it made rather than whichever path got there first.
Assert.HasCount(1, reasons);
Assert.IsInstanceOfType<ObsWebSocketException>(reasons[0]);
Assert.Contains("after 1 attempts", reasons[0]!.Message);
Assert.IsInstanceOfType<ConnectionAttemptFailedException>(reasons[0]!.InnerException);
}

[TestMethod]
[Timeout(TestTimeout)]
public async Task DisconnectAsync_ThenRequest_RequestRefused()
Expand Down
Loading