Skip to content

Commit ae98ae8

Browse files
[Backport] [SignalR] Avoid unobserved tasks in the .NET client (#43882)
1 parent 6964079 commit ae98ae8

File tree

5 files changed

+73
-6
lines changed

5 files changed

+73
-6
lines changed

eng/targets/CSharp.Common.targets

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@
116116
-->
117117
<When Condition=" ('$(Nullable)' == 'annotations' OR '$(Nullable)' == 'enable') AND
118118
'$(SuppressNullableAttributesImport)' != 'true' AND
119-
(('$(TargetFrameworkIdentifier)' == '.NETStandard' AND $([MSBuild]::VersionLessThanOrEquals('$(TargetFrameworkVersion)', '2.0'))) OR '$(TargetFrameworkIdentifier)' == '.NETFramework')">
119+
(('$(TargetFrameworkIdentifier)' == '.NETStandard' AND $([MSBuild]::VersionLessThanOrEquals('$(TargetFrameworkVersion)', '2.1'))) OR '$(TargetFrameworkIdentifier)' == '.NETFramework')">
120120
<PropertyGroup>
121121
<DefineConstants>$(DefineConstants),INTERNAL_NULLABLE_ATTRIBUTES</DefineConstants>
122122
<NoWarn>$(NoWarn);nullable</NoWarn>

src/Shared/Nullable/NullableAttributes.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
namespace System.Diagnostics.CodeAnalysis
77
{
8+
// Attributes added in netstandard2.1
9+
#if !NETSTANDARD2_1_OR_GREATER
810
/// <summary>Specifies that null is allowed as an input even if the corresponding type disallows it.</summary>
911
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)]
1012
#if INTERNAL_NULLABLE_ATTRIBUTES
@@ -126,7 +128,10 @@ sealed class DoesNotReturnIfAttribute : Attribute
126128
/// <summary>Gets the condition parameter value.</summary>
127129
public bool ParameterValue { get; }
128130
}
131+
#endif
129132

133+
// Attributes added in 5.0
134+
#if NETSTANDARD || NETFRAMEWORK
130135
/// <summary>Specifies that the method or property will ensure that the listed field and property members have not-null values.</summary>
131136
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)]
132137
#if INTERNAL_NULLABLE_ATTRIBUTES
@@ -193,4 +198,5 @@ public MemberNotNullWhenAttribute(bool returnValue, params string[] members)
193198
/// <summary>Gets field or property member names.</summary>
194199
public string[] Members { get; }
195200
}
201+
#endif
196202
}

src/SignalR/clients/csharp/Client.Core/src/HubConnection.Log.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ namespace Microsoft.AspNetCore.SignalR.Client
1010
{
1111
public partial class HubConnection
1212
{
13-
private static class Log
13+
private static partial class Log
1414
{
1515
private static readonly LogDefineOptions SkipEnabledCheckLogOptions = new() { SkipEnabledCheck = true };
1616

@@ -254,6 +254,11 @@ private static class Log
254254
private static readonly Action<ILogger, string, Exception?> _erroredStream =
255255
LoggerMessage.Define<string>(LogLevel.Trace, new EventId(84, "ErroredStream"), "Client threw an error for stream '{StreamId}'.");
256256

257+
[LoggerMessage(87, LogLevel.Trace, "Completion message for stream '{StreamId}' was not sent because the connection is closed.", EventName = "CompletingStreamNotSent")]
258+
public static partial void CompletingStreamNotSent(ILogger logger, string streamId);
259+
260+
// EventId 88 used in 7.0+
261+
257262
public static void PreparingNonBlockingInvocation(ILogger logger, string target, int count)
258263
{
259264
_preparingNonBlockingInvocation(logger, target, count, null);

src/SignalR/clients/csharp/Client.Core/src/HubConnection.cs

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -782,11 +782,26 @@ private async Task CommonStreaming(ConnectionState connectionState, string strea
782782
responseError = $"Stream errored by client: '{ex}'";
783783
}
784784

785-
Log.CompletingStream(_logger, streamId);
786-
787785
// Don't use cancellation token here
788786
// this is triggered by a cancellation token to tell the server that the client is done streaming
789-
await SendWithLock(connectionState, CompletionMessage.WithError(streamId, responseError), cancellationToken: default);
787+
await _state.WaitConnectionLockAsync(token: default).ConfigureAwait(false);
788+
try
789+
{
790+
// Avoid sending when the connection isn't active, likely happens if there is an active stream when the connection closes
791+
if (_state.IsConnectionActive())
792+
{
793+
Log.CompletingStream(_logger, streamId);
794+
await SendHubMessage(connectionState, CompletionMessage.WithError(streamId, responseError), cancellationToken: default).ConfigureAwait(false);
795+
}
796+
else
797+
{
798+
Log.CompletingStreamNotSent(_logger, streamId);
799+
}
800+
}
801+
finally
802+
{
803+
_state.ReleaseConnectionLock();
804+
}
790805
}
791806

792807
private async Task<object?> InvokeCoreAsyncCore(string methodName, Type returnType, object?[] args, CancellationToken cancellationToken)
@@ -2007,7 +2022,7 @@ public async Task<ConnectionState> WaitForActiveConnectionAsync(string methodNam
20072022
{
20082023
await WaitConnectionLockAsync(token, methodName);
20092024

2010-
if (CurrentConnectionStateUnsynchronized == null || CurrentConnectionStateUnsynchronized.Stopping)
2025+
if (!IsConnectionActive())
20112026
{
20122027
ReleaseConnectionLock(methodName);
20132028
throw new InvalidOperationException($"The '{methodName}' method cannot be called if the connection is not active");
@@ -2016,6 +2031,13 @@ public async Task<ConnectionState> WaitForActiveConnectionAsync(string methodNam
20162031
return CurrentConnectionStateUnsynchronized;
20172032
}
20182033

2034+
[MemberNotNullWhen(true, nameof(CurrentConnectionStateUnsynchronized))]
2035+
public bool IsConnectionActive()
2036+
{
2037+
AssertInConnectionLock();
2038+
return CurrentConnectionStateUnsynchronized is not null && !CurrentConnectionStateUnsynchronized.Stopping;
2039+
}
2040+
20192041
public void ReleaseConnectionLock([CallerMemberName] string? memberName = null,
20202042
[CallerFilePath] string? filePath = null, [CallerLineNumber] int lineNumber = 0)
20212043
{

src/SignalR/clients/csharp/Client/test/UnitTests/HubConnectionTests.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,40 @@ public async Task UploadStreamErrorSendsStreamComplete()
544544
}
545545
}
546546

547+
[Fact]
548+
[LogLevel(LogLevel.Trace)]
549+
public async Task ActiveUploadStreamWhenConnectionClosesObservesException()
550+
{
551+
using (StartVerifiableLog())
552+
{
553+
var connection = new TestConnection();
554+
var hubConnection = CreateHubConnection(connection, loggerFactory: LoggerFactory);
555+
await hubConnection.StartAsync().DefaultTimeout();
556+
557+
var channel = Channel.CreateUnbounded<int>();
558+
var invokeTask = hubConnection.InvokeAsync<object>("UploadMethod", channel.Reader);
559+
560+
var invokeMessage = await connection.ReadSentJsonAsync().DefaultTimeout();
561+
Assert.Equal(HubProtocolConstants.InvocationMessageType, invokeMessage["type"]);
562+
563+
// Not sure how to test for unobserved task exceptions, best I could come up with is to check that we log where there once was an unobserved task exception
564+
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
565+
TestSink.MessageLogged += wc =>
566+
{
567+
if (wc.EventId.Name == "CompletingStreamNotSent")
568+
{
569+
tcs.SetResult();
570+
}
571+
};
572+
573+
await hubConnection.StopAsync();
574+
575+
await Assert.ThrowsAsync<TaskCanceledException>(() => invokeTask).DefaultTimeout();
576+
577+
await tcs.Task.DefaultTimeout();
578+
}
579+
}
580+
547581
[Fact]
548582
[LogLevel(LogLevel.Trace)]
549583
public async Task InvocationCanCompleteBeforeStreamCompletes()

0 commit comments

Comments
 (0)