Skip to content

Commit 67708eb

Browse files
AArnottCopilot
andauthored
Add JsonRpc outbound request timeout (#1471)
* Add outbound request timeout support Add a JsonRpc-wide OutboundRequestTimeout that applies to response-expected outbound calls, turns timeout-origin failures into TimeoutException, preserves caller cancellation tokens, and exercises the behavior across direct, proxy, and cancellation-strategy tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR feedback on outbound timeouts Translate timeout-driven send-path cancellations into TimeoutException, align most timeout tests with ExpectedTimeout, and keep the custom cancellation-strategy timeout at 100ms because the larger shared timeout hangs on net472 in that scenario. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Stabilize outbound-timeout tests on Windows CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR timeout race and atomicity feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d931926 commit 67708eb

7 files changed

Lines changed: 297 additions & 19 deletions

File tree

src/StreamJsonRpc/JsonRpc.cs

Lines changed: 94 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ public class JsonRpc : IDisposableObservable, IJsonRpcFormatterCallbacks, IJsonR
126126
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
127127
private bool cancelLocallyInvokedMethodsWhenConnectionIsClosed;
128128

129+
/// <summary>
130+
/// Backing field for the <see cref="OutboundRequestTimeout"/> property.
131+
/// Stores timeout ticks, with 0 representing <see langword="null"/>.
132+
/// </summary>
133+
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
134+
private long outboundRequestTimeoutTicks;
135+
129136
/// <summary>
130137
/// Backing field for the <see cref="SynchronizationContext"/> property.
131138
/// </summary>
@@ -590,6 +597,47 @@ public bool CancelLocallyInvokedMethodsWhenConnectionIsClosed
590597
}
591598
}
592599

600+
/// <summary>
601+
/// Gets or sets the timeout to apply to each outbound invocation that expects a response.
602+
/// </summary>
603+
/// <value>
604+
/// The timeout, or <see langword="null"/> to disable this behavior.
605+
/// The default value is <see langword="null"/>.
606+
/// </value>
607+
/// <remarks>
608+
/// <para>
609+
/// This timeout is applied to outbound method invocations (such as <see cref="InvokeWithCancellationAsync(string, IReadOnlyList{object?}?, CancellationToken)"/> and proxy method calls),
610+
/// but not notifications.
611+
/// </para>
612+
/// <para>
613+
/// When a timeout triggers, a notification is sent to the server to request cancellation
614+
/// and the local invocation is immediately canceled allowing the caller to handle the timeout
615+
/// as a <see cref="TimeoutException"/>.
616+
/// </para>
617+
/// </remarks>
618+
/// <exception cref="ArgumentOutOfRangeException">Thrown if a non-positive timeout value is assigned.</exception>
619+
public TimeSpan? OutboundRequestTimeout
620+
{
621+
get
622+
{
623+
long timeoutTicks = Interlocked.Read(ref this.outboundRequestTimeoutTicks);
624+
return timeoutTicks == 0 ? null : TimeSpan.FromTicks(timeoutTicks);
625+
}
626+
627+
set
628+
{
629+
if (value is TimeSpan timeout)
630+
{
631+
Requires.Range(timeout > TimeSpan.Zero, nameof(value), Resources.PositiveTimeSpanRequired);
632+
Interlocked.Exchange(ref this.outboundRequestTimeoutTicks, timeout.Ticks);
633+
}
634+
else
635+
{
636+
Interlocked.Exchange(ref this.outboundRequestTimeoutTicks, 0);
637+
}
638+
}
639+
}
640+
593641
/// <summary>
594642
/// Gets or sets the <see cref="System.Diagnostics.TraceSource"/> used to trace JSON-RPC messages and events.
595643
/// </summary>
@@ -2120,9 +2168,19 @@ private JsonRpcError CreateCancellationResponse(JsonRpcRequest request)
21202168
Requires.NotNull(request, nameof(request));
21212169
Assumes.NotNull(request.Method);
21222170

2171+
CancellationToken effectiveOutboundCancellationToken = cancellationToken;
2172+
CancellationTokenSource? timeoutCancellationSource = null;
2173+
if (request.IsResponseExpected && this.OutboundRequestTimeout is TimeSpan outboundRequestTimeout)
2174+
{
2175+
timeoutCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
2176+
timeoutCancellationSource.CancelAfter(outboundRequestTimeout);
2177+
effectiveOutboundCancellationToken = timeoutCancellationSource.Token;
2178+
}
2179+
21232180
try
21242181
{
2125-
using (CancellationTokenExtensions.CombinedCancellationToken cts = this.DisconnectedToken.CombineWith(cancellationToken))
2182+
using (timeoutCancellationSource)
2183+
using (CancellationTokenExtensions.CombinedCancellationToken cts = this.DisconnectedToken.CombineWith(effectiveOutboundCancellationToken))
21262184
{
21272185
if (!request.IsResponseExpected)
21282186
{
@@ -2161,10 +2219,10 @@ private JsonRpcError CreateCancellationResponse(JsonRpcRequest request)
21612219
JsonRpcEventSource.Instance.ReceivedNoResponse(request.RequestId.NumberIfPossibleForEvent);
21622220
}
21632221

2164-
if (cancellationToken.IsCancellationRequested)
2222+
if (effectiveOutboundCancellationToken.IsCancellationRequested)
21652223
{
21662224
// Consider lost connection to be result of task canceled and set state to canceled.
2167-
tcs.TrySetCanceled(cancellationToken);
2225+
tcs.TrySetCanceled(effectiveOutboundCancellationToken);
21682226
}
21692227
else
21702228
{
@@ -2180,7 +2238,7 @@ private JsonRpcError CreateCancellationResponse(JsonRpcRequest request)
21802238

21812239
if (error.Error?.Code == JsonRpcErrorCode.RequestCanceled)
21822240
{
2183-
tcs.TrySetCanceled(cancellationToken.IsCancellationRequested ? cancellationToken : CancellationToken.None);
2241+
tcs.TrySetCanceled(effectiveOutboundCancellationToken.IsCancellationRequested ? effectiveOutboundCancellationToken : CancellationToken.None);
21842242
}
21852243
else
21862244
{
@@ -2241,22 +2299,49 @@ private JsonRpcError CreateCancellationResponse(JsonRpcRequest request)
22412299
// Arrange for sending a cancellation message if canceled while we're waiting for a response.
22422300
try
22432301
{
2244-
using (cancellationToken.Register(this.cancelPendingOutboundRequestAction!, request.RequestId, useSynchronizationContext: false))
2302+
using (effectiveOutboundCancellationToken.Register(this.cancelPendingOutboundRequestAction!, request.RequestId, useSynchronizationContext: false))
22452303
{
2246-
// This task will be completed when the Response object comes back from the other end of the pipe
2247-
return await tcs.Task.ConfigureAwait(false);
2304+
// This task will be completed when the Response object comes back from the other end of the pipe.
2305+
try
2306+
{
2307+
if (timeoutCancellationSource is null)
2308+
{
2309+
return await tcs.Task.ConfigureAwait(false);
2310+
}
2311+
2312+
Task completedTask = await Task.WhenAny(tcs.Task, Task.Delay(Timeout.Infinite, effectiveOutboundCancellationToken)).ConfigureAwait(false);
2313+
if (completedTask == tcs.Task)
2314+
{
2315+
return await tcs.Task.ConfigureAwait(false);
2316+
}
2317+
2318+
effectiveOutboundCancellationToken.ThrowIfCancellationRequested();
2319+
throw Assumes.NotReachable();
2320+
}
2321+
catch (OperationCanceledException ex) when (timeoutCancellationSource?.IsCancellationRequested is true && !cancellationToken.IsCancellationRequested)
2322+
{
2323+
throw new TimeoutException(Resources.FormatOutboundInvocationTimedOut(nameof(this.OutboundRequestTimeout)), ex);
2324+
}
22482325
}
22492326
}
22502327
finally
22512328
{
2252-
if (cancellationToken.IsCancellationRequested)
2329+
if (effectiveOutboundCancellationToken.IsCancellationRequested)
22532330
{
22542331
this.CancellationStrategy?.OutboundRequestEnded(request.RequestId);
22552332
}
22562333
}
22572334
}
22582335
}
2259-
catch (OperationCanceledException ex) when (this.DisconnectedToken.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
2336+
catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested && ex.CancellationToken != cancellationToken)
2337+
{
2338+
throw new OperationCanceledException(ex.Message, ex, cancellationToken);
2339+
}
2340+
catch (OperationCanceledException ex) when (timeoutCancellationSource?.IsCancellationRequested is true && !cancellationToken.IsCancellationRequested)
2341+
{
2342+
throw new TimeoutException(Resources.FormatOutboundInvocationTimedOut(nameof(this.OutboundRequestTimeout)), ex);
2343+
}
2344+
catch (OperationCanceledException ex) when (this.DisconnectedToken.IsCancellationRequested && !effectiveOutboundCancellationToken.IsCancellationRequested)
22602345
{
22612346
throw new ConnectionLostException(Resources.ConnectionDropped, ex);
22622347
}

src/StreamJsonRpc/Resources.resx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,9 @@
256256
<data name="PositiveIntegerRequired" xml:space="preserve">
257257
<value>A positive integer is required.</value>
258258
</data>
259+
<data name="PositiveTimeSpanRequired" xml:space="preserve">
260+
<value>A positive TimeSpan is required.</value>
261+
</data>
259262
<data name="ReachedEndOfStream" xml:space="preserve">
260263
<value>Reached end of stream.</value>
261264
</data>
@@ -321,6 +324,9 @@
321324
<data name="TaskWasCancelled" xml:space="preserve">
322325
<value>The task was cancelled.</value>
323326
</data>
327+
<data name="OutboundInvocationTimedOut" xml:space="preserve">
328+
<value>The outbound JSON-RPC invocation was canceled automatically after reaching the timeout configured by {0}. The server may still be processing the request.</value>
329+
</data>
324330
<data name="TextEncoderNotApplicable" xml:space="preserve">
325331
<value>Text encoding is not supported because the formatter "{0}" does not implement "{1}".</value>
326332
<comment>{0} and {1} are CLR type names.</comment>

test/StreamJsonRpc.Tests/CustomCancellationStrategyTests.cs

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,18 @@ public async Task CancelRequest_ServerMethodThrows()
9191
await this.mockStrategy.OutboundRequestEndedInvoked.WaitAsync(this.TimeoutToken);
9292
}
9393

94+
[Fact]
95+
public async Task CancelRequest_WhenOutboundRequestTimesOut()
96+
{
97+
this.clientRpc.OutboundRequestTimeout = TimeSpan.FromSeconds(1);
98+
Task invokeTask = this.clientRpc.InvokeWithCancellationAsync(nameof(Server.NoticeCancellationAsync), new object?[] { false }, cancellationToken: CancellationToken.None);
99+
await this.server.MethodEntered.WaitAsync(this.TimeoutToken);
100+
101+
TimeoutException ex = await Assert.ThrowsAsync<TimeoutException>(() => invokeTask);
102+
Assert.Contains(nameof(JsonRpc.OutboundRequestTimeout), ex.Message, StringComparison.Ordinal);
103+
Assert.True(this.mockStrategy.OutboundRequestEndedCalled);
104+
}
105+
94106
[Fact]
95107
public async Task UncanceledRequest_GetsNoClientSideInvocations()
96108
{
@@ -114,11 +126,9 @@ public async Task OutboundCancellationStartAndRequestFinishOverlap()
114126
var completingTask = await Task.WhenAny(invokeTask, this.server.MethodEntered.WaitAsync(TestContext.Current.CancellationToken)).WithCancellation(this.TimeoutToken);
115127
await completingTask; // rethrow an exception if there is one.
116128

129+
this.mockStrategy.BlockCancelOutboundRequest = true;
117130
this.mockStrategy.AllowCancelOutboundRequestToExit.Reset();
118131
cts.Cancel();
119-
120-
// This may be invoked, but if the product doesn't invoke it, that's ok too.
121-
////await this.mockStrategy.OutboundRequestEndedInvoked.WaitAsync(this.TimeoutToken);
122132
}
123133

124134
protected virtual void InitializeFormattersAndHandlers()
@@ -170,10 +180,16 @@ internal MockCancellationStrategy(CustomCancellationStrategyTests owner, ITestOu
170180

171181
internal bool CancelRequestMade { get; private set; }
172182

173-
internal AsyncAutoResetEvent OutboundRequestEndedInvoked { get; } = new AsyncAutoResetEvent();
183+
internal bool OutboundRequestEndedCalled { get; private set; }
184+
185+
internal AsyncManualResetEvent CancelOutboundRequestInvoked { get; } = new AsyncManualResetEvent();
186+
187+
internal AsyncManualResetEvent OutboundRequestEndedInvoked { get; } = new AsyncManualResetEvent();
174188

175189
internal ManualResetEventSlim AllowCancelOutboundRequestToExit { get; } = new ManualResetEventSlim(initialState: true);
176190

191+
internal bool BlockCancelOutboundRequest { get; set; }
192+
177193
public void CancelOutboundRequest(RequestId requestId)
178194
{
179195
this.logger.WriteLine($"{nameof(this.CancelOutboundRequest)}({requestId})");
@@ -192,20 +208,25 @@ public void CancelOutboundRequest(RequestId requestId)
192208

193209
cts?.Cancel();
194210
this.CancelRequestMade = true;
211+
this.CancelOutboundRequestInvoked.Set();
195212

196-
// Wait for the out of order invocation to happen if it's possible,
197-
// so the OutboundCancellationStartAndRequestFinishOverlap test can catch it.
198-
// Otherwise timeout, which is necessary to avoid a test hang when the product DOES work,
199-
// since it shouldn't allow OutboundRequestEnded to execute before this method exits.
200-
if (!this.AllowCancelOutboundRequestToExit.Wait(ExpectedTimeout))
213+
if (this.BlockCancelOutboundRequest)
201214
{
202-
this.logger.WriteLine("Timed out waiting for " + nameof(this.AllowCancelOutboundRequestToExit) + " to be signaled (good thing).");
215+
// Wait for the out of order invocation to happen if it's possible,
216+
// so the OutboundCancellationStartAndRequestFinishOverlap test can catch it.
217+
// Otherwise timeout, which is necessary to avoid a test hang when the product DOES work,
218+
// since it shouldn't allow OutboundRequestEnded to execute before this method exits.
219+
if (!this.AllowCancelOutboundRequestToExit.Wait(ExpectedTimeout))
220+
{
221+
this.logger.WriteLine("Timed out waiting for " + nameof(this.AllowCancelOutboundRequestToExit) + " to be signaled (good thing).");
222+
}
203223
}
204224
}
205225

206226
public void OutboundRequestEnded(RequestId requestId)
207227
{
208228
this.logger.WriteLine($"{nameof(this.OutboundRequestEnded)}({requestId}) invoked.");
229+
this.OutboundRequestEndedCalled = true;
209230
lock (this.endedRequestIds)
210231
{
211232
this.endedRequestIds.Add(requestId);

test/StreamJsonRpc.Tests/JsonRpcClient20InteropTests.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,16 @@ public async Task CancelMessageSentAndResponseCompletes()
273273
}
274274
}
275275

276+
[Fact]
277+
public async Task NotifyAsync_IgnoresOutboundRequestTimeout()
278+
{
279+
this.clientRpc.OutboundRequestTimeout = TimeSpan.FromMilliseconds(1);
280+
await this.clientRpc.NotifyAsync("test");
281+
JToken request = await this.ReceiveAsync();
282+
Assert.Equal("test", request["method"]?.ToString());
283+
Assert.Null(request["id"]);
284+
}
285+
276286
[Fact]
277287
public async Task ErrorResponseIncludesCallstack()
278288
{

test/StreamJsonRpc.Tests/JsonRpcDelegatedDispatchAndSendTests.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,24 @@ public async Task DelegatedDispatcherCanDispatchInReverseOrderBasedOnTopLevelPro
6262
}
6363
}
6464

65+
[Fact]
66+
public async Task InvokeAsync_UsesOutboundRequestTimeoutWhenSendAsyncTimesOut()
67+
{
68+
var streams = Nerdbank.FullDuplexStream.CreateStreams();
69+
using var clientRpc = new BlockingSendJsonRpc(new HeaderDelimitedMessageHandler(streams.Item1));
70+
using var serverRpc = new DelegatedJsonRpc(new HeaderDelimitedMessageHandler(streams.Item2), this.server);
71+
72+
clientRpc.StartListening();
73+
serverRpc.StartListening();
74+
clientRpc.OutboundRequestTimeout = ExpectedTimeout;
75+
clientRpc.BlockRequestSend = true;
76+
Task<int> invokeTask = clientRpc.InvokeAsync<int>(nameof(Server.GetCallCountAsync));
77+
await clientRpc.RequestSendBlocked.WaitAsync(this.TimeoutToken);
78+
79+
TimeoutException ex = await Assert.ThrowsAsync<TimeoutException>(() => invokeTask);
80+
Assert.Contains(nameof(JsonRpc.OutboundRequestTimeout), ex.Message, StringComparison.Ordinal);
81+
}
82+
6583
#pragma warning disable CA1801 // use all parameters
6684
public class Server
6785
{
@@ -158,5 +176,28 @@ protected override ValueTask SendAsync(JsonRpcMessage message, CancellationToken
158176
}
159177
}
160178

179+
private sealed class BlockingSendJsonRpc : DelegatedJsonRpc
180+
{
181+
public BlockingSendJsonRpc(IJsonRpcMessageHandler handler)
182+
: base(handler)
183+
{
184+
}
185+
186+
public bool BlockRequestSend { get; set; }
187+
188+
public AsyncAutoResetEvent RequestSendBlocked { get; } = new AsyncAutoResetEvent();
189+
190+
protected override async ValueTask SendAsync(JsonRpcMessage message, CancellationToken cancellationToken)
191+
{
192+
if (this.BlockRequestSend && message is JsonRpcRequest)
193+
{
194+
this.RequestSendBlocked.Set();
195+
await Task.Delay(Timeout.Infinite, cancellationToken);
196+
}
197+
198+
await base.SendAsync(message, cancellationToken);
199+
}
200+
}
201+
161202
#pragma warning restore CA1801 // use all parameters
162203
}

test/StreamJsonRpc.Tests/JsonRpcProxyGenerationTests.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,18 @@ public async Task CallMethod_CancellationToken_int()
548548
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => task);
549549
}
550550

551+
[Fact]
552+
public async Task CallMethod_UsesJsonRpcOutboundRequestTimeout()
553+
{
554+
this.clientJsonRpc.OutboundRequestTimeout = ExpectedTimeout;
555+
this.server.ResumeMethod.Reset();
556+
Task task = this.clientRpc.HeavyWorkAsync(CancellationToken.None);
557+
await this.server.MethodEntered.WaitAsync(TestContext.Current.CancellationToken).WithCancellation(this.TimeoutToken);
558+
559+
TimeoutException ex = await Assert.ThrowsAsync<TimeoutException>(() => task);
560+
Assert.Contains(nameof(JsonRpc.OutboundRequestTimeout), ex.Message, StringComparison.Ordinal);
561+
}
562+
551563
[Fact]
552564
public async Task CallMethod_intCancellationToken_int()
553565
{

0 commit comments

Comments
 (0)