-
Notifications
You must be signed in to change notification settings - Fork 102
Fix PingCommand cancellation
#1086
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| using NATS.Client.TestUtilities; | ||
| using Synadia.Orbit.Testing.NatsServerProcessManager; | ||
|
|
||
| namespace NATS.Client.Core.Tests; | ||
|
|
||
| public class PingCancellationTest | ||
| { | ||
| private readonly ITestOutputHelper _output; | ||
|
|
||
| public PingCancellationTest(ITestOutputHelper output) => _output = output; | ||
|
|
||
| [Fact] | ||
| public async Task PingAsync_succeeds_with_mock_server() | ||
| { | ||
| using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); | ||
|
|
||
| await using var server = new MockServer( | ||
| handler: (_, _) => Task.CompletedTask, | ||
| logger: m => _output.WriteLine(m), | ||
| cancellationToken: cts.Token); | ||
|
|
||
| await using var nats = new NatsConnection(new NatsOpts { Url = server.Url }); | ||
| await nats.ConnectAsync(); | ||
|
|
||
| var rtt = await nats.PingAsync(cts.Token); | ||
| rtt.Should().BeGreaterThan(TimeSpan.Zero); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task PingAsync_multiple_sequential_pings_succeed() | ||
| { | ||
| using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); | ||
|
|
||
| await using var server = new MockServer( | ||
| handler: (_, _) => Task.CompletedTask, | ||
| logger: m => _output.WriteLine(m), | ||
| cancellationToken: cts.Token); | ||
|
|
||
| await using var nats = new NatsConnection(new NatsOpts { Url = server.Url }); | ||
| await nats.ConnectAsync(); | ||
|
|
||
| for (var i = 0; i < 5; i++) | ||
| { | ||
| var rtt = await nats.PingAsync(cts.Token); | ||
| rtt.Should().BeGreaterThan(TimeSpan.Zero); | ||
| } | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task PingAsync_throws_when_cancelled_waiting_for_pong() | ||
| { | ||
| using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); | ||
| var pingCount = 0; | ||
|
|
||
| // autoPong: false — handler replies PONG only for the first PING (the connect handshake) | ||
| await using var server = new MockServer( | ||
| handler: async (client, cmd) => | ||
| { | ||
| if (cmd.Name == "PING") | ||
| { | ||
| var n = Interlocked.Increment(ref pingCount); | ||
| client.Log($"[S] PING #{n}"); | ||
|
|
||
| if (n == 1) | ||
| { | ||
| // Reply to the connect-time PING so the connection opens | ||
| await client.Writer.WriteAsync("PONG\r\n"); | ||
| await client.Writer.FlushAsync(); | ||
| } | ||
|
|
||
| // Subsequent PINGs get no PONG | ||
| } | ||
| }, | ||
| logger: m => _output.WriteLine(m), | ||
| autoPong: false, | ||
| cancellationToken: cts.Token); | ||
|
|
||
| await using var nats = new NatsConnection(new NatsOpts { Url = server.Url }); | ||
| await nats.ConnectAsync(); | ||
|
|
||
| // This ping should time out because the server won't reply PONG | ||
| using var pingCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500)); | ||
|
|
||
| var act = () => nats.PingAsync(pingCts.Token).AsTask(); | ||
| await act.Should().ThrowAsync<OperationCanceledException>(); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task PingAsync_concurrent_pings_all_complete() | ||
| { | ||
| using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); | ||
|
|
||
| await using var server = new MockServer( | ||
| handler: (_, _) => Task.CompletedTask, | ||
| logger: m => _output.WriteLine(m), | ||
| cancellationToken: cts.Token); | ||
|
|
||
| await using var nats = new NatsConnection(new NatsOpts { Url = server.Url }); | ||
| await nats.ConnectAsync(); | ||
|
|
||
| // Fire multiple pings concurrently — exercises the pool and concurrent SetResult paths | ||
| var tasks = new Task<TimeSpan>[10]; | ||
| for (var i = 0; i < tasks.Length; i++) | ||
| { | ||
| tasks[i] = nats.PingAsync(cts.Token).AsTask(); | ||
| } | ||
|
|
||
| var results = await Task.WhenAll(tasks); | ||
| foreach (var rtt in results) | ||
| { | ||
| rtt.Should().BeGreaterOrEqualTo(TimeSpan.Zero); | ||
| } | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task PingAsync_succeeds_against_real_server() | ||
| { | ||
| await using var server = await NatsServerProcess.StartAsync(); | ||
|
|
||
| await using var nats = new NatsConnection(new NatsOpts { Url = server.Url }); | ||
| await nats.ConnectAsync(); | ||
|
|
||
| var rtt = await nats.PingAsync(); | ||
| rtt.Should().BeGreaterThan(TimeSpan.Zero); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task PingAsync_times_out_after_server_stopped() | ||
| { | ||
| await using var server = await NatsServerProcess.StartAsync(); | ||
|
|
||
| await using var nats = new NatsConnection(new NatsOpts { Url = server.Url }); | ||
| await nats.ConnectAsync(); | ||
|
|
||
| // Verify ping works while server is up | ||
| var rtt = await nats.PingAsync(); | ||
| rtt.Should().BeGreaterThan(TimeSpan.Zero); | ||
| _output.WriteLine($"Ping RTT before stop: {rtt}"); | ||
|
|
||
| // Stop the server — no more PONGs | ||
| await server.StopAsync(); | ||
| _output.WriteLine("Server stopped"); | ||
|
|
||
| // Ping with a timeout should throw since no PONG will arrive | ||
| using var pingCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); | ||
|
|
||
| var act = () => nats.PingAsync(pingCts.Token).AsTask(); | ||
| await act.Should().ThrowAsync<OperationCanceledException>(); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| namespace NATS.Client.Core.Tests; | ||
|
|
||
| public class PingCommandTest | ||
| { | ||
| [Fact] | ||
| public async Task SetResult_DeliversElapsedTime() | ||
| { | ||
| var cmd = new PingCommand(pool: null); | ||
| cmd.Start(); | ||
| await Task.Delay(50); | ||
| cmd.SetResult(); | ||
|
|
||
| var elapsed = await cmd.RunAsync(); | ||
| elapsed.TotalMilliseconds.Should().BeGreaterThan(0); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task SetCanceled_ThrowsOperationCanceledException() | ||
| { | ||
| var cmd = new PingCommand(pool: null); | ||
| cmd.Start(); | ||
| cmd.SetCanceled(); | ||
|
|
||
| var act = () => cmd.RunAsync().AsTask(); | ||
| await act.Should().ThrowAsync<OperationCanceledException>(); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void SetResult_CalledTwice_DoesNotThrow() | ||
| { | ||
| var cmd = new PingCommand(pool: null); | ||
| cmd.Start(); | ||
| cmd.SetResult(); | ||
| cmd.SetResult(); // second call should be a no-op | ||
| } | ||
|
|
||
| [Fact] | ||
| public void SetCanceled_CalledTwice_DoesNotThrow() | ||
| { | ||
| var cmd = new PingCommand(pool: null); | ||
| cmd.Start(); | ||
| cmd.SetCanceled(); | ||
| cmd.SetCanceled(); // second call should be a no-op | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task SetResult_AfterSetCanceled_StillThrows() | ||
| { | ||
| var cmd = new PingCommand(pool: null); | ||
| cmd.Start(); | ||
| cmd.SetCanceled(); | ||
| cmd.SetResult(); // should be ignored | ||
|
|
||
| var act = () => cmd.RunAsync().AsTask(); | ||
| await act.Should().ThrowAsync<OperationCanceledException>(); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task SetCanceled_AfterSetResult_StillReturnsResult() | ||
| { | ||
| var cmd = new PingCommand(pool: null); | ||
| cmd.Start(); | ||
| cmd.SetResult(); | ||
| cmd.SetCanceled(); // should be ignored | ||
|
|
||
| var elapsed = await cmd.RunAsync(); | ||
| elapsed.TotalMilliseconds.Should().BeGreaterOrEqualTo(0); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task ConcurrentSetResultAndSetCanceled_DoesNotThrow() | ||
| { | ||
| // Run multiple times to increase chance of hitting the race | ||
| for (var i = 0; i < 100; i++) | ||
| { | ||
| var cmd = new PingCommand(pool: null); | ||
| cmd.Start(); | ||
|
|
||
| var t1 = Task.Run(() => cmd.SetResult()); | ||
| var t2 = Task.Run(() => cmd.SetCanceled()); | ||
| await Task.WhenAll(t1, t2); | ||
|
|
||
| // Should not throw - either result or cancellation wins | ||
| try | ||
| { | ||
| await cmd.RunAsync(); | ||
| } | ||
| catch (OperationCanceledException) | ||
| { | ||
| // This is also acceptable | ||
| } | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The cancellation callback completes the PingCommand while it may still be enqueued in the reader’s _pingCommands queue. Since PingCommand.GetResult() returns the instance to the object pool, a cancelled ping can be reset/reused while still sitting in the queue; the next PONG will dequeue that same instance and call SetResult(), potentially completing a different (reused) ping. To fix this, ensure a PingCommand is not reset/returned to the pool until it has been removed from the ping queue (e.g., move pooling/Reset out of PingCommand.GetResult and return it from the dequeue site, or introduce a dequeue/removal mechanism on cancellation).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
False positive. When
SetCanceled()is called,GetResult()throwsOperationCanceledException, so the pool return code is never reached. The cancelled command stays in_pingCommandsbut the_completedflag makes the eventualSetResult()fromPONGa no-op. No fix needed.