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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.10.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="Shouldly" Version="4.3.0" />
Expand Down
21 changes: 13 additions & 8 deletions src/Linq2GraphQL.Client.Subscriptions/IdleTimeoutStream.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ internal sealed class IdleTimeoutStream : Stream
{
private readonly Stream inner;
private readonly TimeSpan idleTimeout;
private readonly TimeProvider timeProvider;
private bool disposed;

internal IdleTimeoutStream(Stream inner, TimeSpan idleTimeout)
/// <param name="timeProvider">Clock driving the idle timer; <see cref="TimeProvider.System" /> when null.
/// Tests pass a fake provider so the timer only advances when they say so.</param>
internal IdleTimeoutStream(Stream inner, TimeSpan idleTimeout, TimeProvider timeProvider = null)
{
if (idleTimeout <= TimeSpan.Zero)
{
Expand All @@ -22,6 +25,7 @@ internal IdleTimeoutStream(Stream inner, TimeSpan idleTimeout)

this.inner = inner ?? throw new ArgumentNullException(nameof(inner));
this.idleTimeout = idleTimeout;
this.timeProvider = timeProvider ?? TimeProvider.System;
}

public override bool CanRead => inner.CanRead;
Expand All @@ -37,20 +41,21 @@ public override async ValueTask<int> ReadAsync(Memory<byte> buffer, Cancellation
// A fresh timer per read, disposed when the read completes: it runs only while waiting
// for data, and a timer that fired during a read which still returned bytes cannot leak
// into the next read (a CancellationTokenSource cannot be reset once cancelled).
// CreateLinkedTokenSource with a non-cancelable token is equivalent to a plain source.
using var readCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
readCts.CancelAfter(idleTimeout);
// The timer source is bound to the TimeProvider (a linked source cannot be), then linked
// with the caller's token for the read itself.
using var timerCts = new CancellationTokenSource(idleTimeout, timeProvider);
using var readCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timerCts.Token);

try
{
return await inner.ReadAsync(buffer, readCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (
readCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
timerCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
// Only the idle timer or the caller can cancel readCts; it fired and the caller did
// not cancel, so the timer fired with no bytes received: the connection is dead. An
// OperationCanceledException the inner stream raised on its own propagates unchanged.
// The idle timer fired and the caller did not cancel, so no bytes arrived within the
// window: the connection is dead. An OperationCanceledException the inner stream
// raised on its own propagates unchanged.
throw new IOException($"SSE idle timeout: no data received within {FormatTimeout(idleTimeout)}.");
}
}
Expand Down
5 changes: 4 additions & 1 deletion src/Linq2GraphQL.Client.Subscriptions/SSEClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ public SSEClient(GraphClient graphClient, GraphQLRequest payload)
this.payload = payload;
}

/// <summary>Clock for the idle timeout; tests substitute a fake provider.</summary>
internal TimeProvider TimeProvider { get; init; } = TimeProvider.System;

/// <summary>
/// UTF-8 payloads of "next" events decoded to strings — deferred per subscriber:
/// the decode only happens for listeners actually attached here.
Expand Down Expand Up @@ -154,7 +157,7 @@ internal async Task Start(CancellationToken cancellationToken)
var idleTimeout = graphClient.ClientOptions?.SubscriptionIdleTimeout;
if (idleTimeout is { } timeout)
{
source = new IdleTimeoutStream(rawStream, timeout);
source = new IdleTimeoutStream(rawStream, timeout, TimeProvider);
}

lock (resourceLock)
Expand Down
19 changes: 12 additions & 7 deletions test/Linq2GraphQL.Tests/IdleTimeoutStreamTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Text;
using Linq2GraphQL.Client.Subscriptions;
using Microsoft.Extensions.Time.Testing;

namespace Linq2GraphQL.Tests;

Expand Down Expand Up @@ -43,11 +44,12 @@ public async Task DataArrivingWithinWindow_ReadsSucceed()
[Fact]
public async Task SlowButRegularData_DoesNotTimeOut()
{
// One byte every 50ms for ~1s with a 750ms idle timeout: the whole read outlasts the
// timeout, so it only passes if the timer re-arms per read. The 15x per-gap margin keeps
// it stable when the full suite loads the thread pool.
using var stream = new IdleTimeoutStream(new DrippingStream(TimeSpan.FromMilliseconds(50), 20),
TimeSpan.FromMilliseconds(750));
// One byte every 500ms (fake clock) for 10s with a 750ms idle timeout: the whole read
// outlasts the timeout, so it only passes if the timer re-arms per read. The clock only
// moves inside each read, so scheduling delays cannot fire the timer.
var time = new FakeTimeProvider();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional — NoDataWithinTimeout_FailsWithIOException (line 12) and NonCancelableToken_StillSubjectToIdleTimeout (line 90) still use the real clock with a 100 ms timeout. They only assert that the timer does fire and have a 5 s outer budget, so they can't flake the same way. Was leaving them on the real clock intentional? If you'd like all idle-timeout tests on the same clock, Advance would also make them instant. Fine to ignore.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that was intentional. I'd rather keep them on the real clock, for two reasons:

  • They can't flake under load. They only assert that the timer does fire, with a 5 s budget for a 100 ms timeout. A busy machine can only delay the timeout, not stop it. The flaky test was the opposite case: it had to prove the timer didn't fire.
  • They're the only coverage of the default clock. In production nobody passes a TimeProvider, so IdleTimeoutStream uses TimeProvider.System. If every test used FakeTimeProvider, nothing would check that the default timeout actually works.

So the split is: fake clock where scheduling could break the test, real clock where it can't.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good to hear! Everything fine then! Waiting for you to resolve the conflicts with main to get the fix out!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conflicts with main are resolved in c5a9d0e. Your per-read timer from ae08e55 is kept as is. The only change on top is that each read's timer is created from the optional TimeProvider, so the fake-clock tests drive it. Since a linked token source can't take a TimeProvider, each read now creates a timer source bound to the provider and links it with the caller's token.

While re-checking after the merge, I found the SSE fake-clock tests still passed with the fake clock disconnected, because their 1 s timeout also ran out on the real clock while the test was waiting. They now use a 30 s timeout, so only the fake clock can fire the timer, and disconnecting it makes both tests fail.

Full CI suite green locally (489 tests); the SSE and idle-timeout tests passed 30 runs in a row.

using var stream = new IdleTimeoutStream(new DrippingStream(time, TimeSpan.FromMilliseconds(500), 20),
TimeSpan.FromMilliseconds(750), time);

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var buffer = new byte[32];
Expand Down Expand Up @@ -346,7 +348,7 @@ public override async ValueTask<int> ReadAsync(Memory<byte> buffer, Cancellation
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}

private sealed class DrippingStream(TimeSpan delay, int count) : Stream
private sealed class DrippingStream(FakeTimeProvider time, TimeSpan delay, int count) : Stream
{
private int remaining = count;

Expand All @@ -358,7 +360,10 @@ private sealed class DrippingStream(TimeSpan delay, int count) : Stream

public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
await Task.Delay(delay, cancellationToken);
await Task.Yield();
// The byte "arrives" after the delay; a fired idle timer cancels this read.
time.Advance(delay);
cancellationToken.ThrowIfCancellationRequested();
if (remaining-- <= 0)
{
return 0;
Expand Down
1 change: 1 addition & 0 deletions test/Linq2GraphQL.Tests/Linq2GraphQL.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing"/>
<PackageReference Include="Microsoft.Extensions.AI"/>
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="Moq"/>
<PackageReference Include="Shouldly"/>
Expand Down
47 changes: 34 additions & 13 deletions test/Linq2GraphQL.Tests/SseClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Linq2GraphQL.Client;
using System.Reactive.Linq;
using Linq2GraphQL.Client.Subscriptions;
using Microsoft.Extensions.Time.Testing;

namespace Linq2GraphQL.Tests;

Expand All @@ -13,11 +14,20 @@ namespace Linq2GraphQL.Tests;
/// </summary>
public class SseClientTests
{
private static SSEClient CreateClient(FakeSseHandler handler, GraphClientOptions? options = null)
private static SSEClient CreateClient(FakeSseHandler handler, GraphClientOptions? options = null,
TimeProvider? timeProvider = null)
{
var graphClient = TestGraphClientFactory.Create(handler, options);
var request = new GraphQLRequest { Query = "subscription { customerAdded { customerId } }" };
return new SSEClient(graphClient, request);
return new SSEClient(graphClient, request) { TimeProvider = timeProvider ?? TimeProvider.System };
}

/// <summary>Pushes a chunk and waits until the reader has consumed it and parked on its next read.</summary>
private static async Task PushAndAwaitNextReadAsync(ScriptableSseStream stream, string text)
{
var parked = stream.ParkedReads;
stream.Push(text);
await WaitUntilAsync(() => stream.ParkedReads > parked);
}

private static (List<byte[]> payloads, List<GraphQueryExecutionException> errors, List<Exception> failures)
Expand Down Expand Up @@ -275,14 +285,17 @@ public async Task IdleTimeoutSet_StalledServer_FailsTheStream()
var options = new GraphClientOptions
{
SubscriptionProtocol = SubscriptionProtocol.ServerSentEvents,
SubscriptionIdleTimeout = TimeSpan.FromMilliseconds(100),
SubscriptionIdleTimeout = TimeSpan.FromSeconds(30),
};
var time = new FakeTimeProvider();
var handler = FakeSseHandler.Streaming(out var stream);
using var client = CreateClient(handler, options);
using var client = CreateClient(handler, options, time);
var failures = new List<Exception>();
client.Failures.Subscribe(failures.Add);

var started = client.Start();
await WaitUntilAsync(() => stream.ParkedReads > 0);
time.Advance(TimeSpan.FromSeconds(30));
await started.WaitAsync(TimeSpan.FromSeconds(10));

var failure = Assert.Single(failures);
Expand All @@ -297,27 +310,35 @@ public async Task KeepAliveComments_PreventIdleTimeout()
var options = new GraphClientOptions
{
SubscriptionProtocol = SubscriptionProtocol.ServerSentEvents,
SubscriptionIdleTimeout = TimeSpan.FromMilliseconds(1000),
SubscriptionIdleTimeout = TimeSpan.FromSeconds(30),
};
// Fake clock: the idle timer only moves when the test advances it, after the reader has
// provably re-armed, so thread-pool load cannot make it fire early.
var time = new FakeTimeProvider();
var handler = FakeSseHandler.Streaming(out var stream);
using var client = CreateClient(handler, options);
using var client = CreateClient(handler, options, time);
var (payloads, _, failures) = Subscribe(client);

_ = client.Start();
var started = client.Start();
await client.Connected.WaitAsync(TimeSpan.FromSeconds(10));
for (var i = 0; i < 15; i++)
{
stream.Push(": ka\n\n");
// ~1.5 s total > idle timeout, so only re-arming on comments keeps it alive; the 10x
// per-gap margin keeps it stable when the full suite loads the thread pool.
await Task.Delay(100);
await PushAndAwaitNextReadAsync(stream, ": ka\n\n");
// 15 x 29 s is far beyond the 30 s timeout: only re-arming on comments keeps it alive.
time.Advance(TimeSpan.FromSeconds(29));
}

stream.Push("event: next\ndata: \"alive\"\n\n");
await PushAndAwaitNextReadAsync(stream, "event: next\ndata: \"alive\"\n\n");
await WaitUntilAsync(() => payloads.Count == 1);
Assert.Empty(failures);

// Control: the fake clock really drives the timer — silence past the timeout fails it.
time.Advance(TimeSpan.FromSeconds(30));
await started.WaitAsync(TimeSpan.FromSeconds(10));

Assert.Single(payloads);
Assert.Empty(failures);
var failure = Assert.Single(failures);
Assert.Contains("idle", failure.Message, StringComparison.OrdinalIgnoreCase);
}

[Fact]
Expand Down
9 changes: 9 additions & 0 deletions test/Linq2GraphQL.Tests/TestSupport/SseTestServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ internal sealed class ScriptableSseStream : Stream
private byte[]? current;
private int currentOffset;
private bool ended;
private int parkedReads;

/// <summary>
/// How many times a read has parked on an empty queue. Once it moves past a value
/// sampled before a push, the reader has consumed that push and issued its next read
/// (so a wrapping IdleTimeoutStream has re-armed its timer).
/// </summary>
public int ParkedReads => Volatile.Read(ref parkedReads);

private static TaskCompletionSource<bool> NewSignal()
{
Expand Down Expand Up @@ -91,6 +99,7 @@ public override async Task<int> ReadAsync(byte[] buffer, int offset, int count,
}

wait = available;
Interlocked.Increment(ref parkedReads);
}

await wait.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
Expand Down
6 changes: 6 additions & 0 deletions test/Linq2GraphQL.Tests/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@
"System.Numerics.Tensors": "10.0.12"
}
},
"Microsoft.Extensions.TimeProvider.Testing": {
"type": "Direct",
"requested": "[10.10.0, )",
"resolved": "10.10.0",
"contentHash": "8duZdxuO3Oo8tlyQXVq1drXee6GJC9DjUU97Pq3YaIqf92jrAHsWxbbRKwvjQI48Mer3IANwUKF0elqvHeY4Rw=="
},
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[17.14.1, )",
Expand Down
Loading