Skip to content
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

CancellationToken not cancelling #487

Conversation

dgvives
Copy link
Contributor

@dgvives dgvives commented Feb 3, 2021

Fixes #236
Fixes #375
Fixes #416
Fixes #434
Closes #343
Closes #351
Closes #394

It solves execution stop on DockerClient.Containers.WaitContainerAsync waiting for the task to finish without being able to cancel.

var waitContainerTask = dockerClient.Containers.WaitContainerAsync(createContainerResponse.ID, cts.Token);
  • Task can be cancelled using token or default timeout.
  • Included tests to verify cancellation works both ways.
  • Included tests to several endpoints calling this method.
  • Fixed tests failing on different execution contexts due to new exceptions being thrown.

@dgvives
Copy link
Contributor Author

dgvives commented Feb 3, 2021

Working solution pending tests cases.

@dgvives dgvives closed this Feb 6, 2021
@dgvives
Copy link
Contributor Author

dgvives commented Feb 6, 2021

Closed, tests produce different results on each execution

@dgvives
Copy link
Contributor Author

dgvives commented Feb 7, 2021

Updated tests to include timeout

@dgvives dgvives reopened this Feb 7, 2021
@dgvives dgvives marked this pull request as ready for review February 14, 2021 17:22
@dgvives
Copy link
Contributor Author

dgvives commented Feb 14, 2021

Included tests to validate issues solution.

@dgvives
Copy link
Contributor Author

dgvives commented Feb 15, 2021

Core changes are set to a minimum. However, some issues are complicated to test.
New test cases have been added to validate issue solution, and a common test fixture put in place to avoid collisions between test monitoring events.
All core modifications have been tested and tests are included within this PR.

@jterry75 @galvesribeiro would you have a look on it?

{
string line;
while ((line = await reader.ReadLineAsync()) != null)
string line = await await Task.WhenAny(reader.ReadLineAsync(), tcs.Task);
Copy link
Contributor

Choose a reason for hiding this comment

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

Fascinating idea. You seem to have an extra await though.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, it looks like that.

await Task.WhenAny(task1, task2) would return either task1 or task2.
Then, this task1 or task2 need to be awaited too.

Original idea from #343 by @suneption, I just adjusted it for tests to pass.

Copy link
Contributor

Choose a reason for hiding this comment

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

Crazy. I did not know you could inline await :).


internal static async Task MonitorStreamAsync(Task<Stream> streamTask, DockerClient client, CancellationToken cancel, IProgress<string> progress)
{
var tcs = new TaskCompletionSource<string>();
cancel.Register(() => tcs.TrySetCanceled(default));
Copy link
Contributor

Choose a reason for hiding this comment

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

Why not in a using like the others?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Should be like others by next commit. I'm running tests now before pushing again

Copy link
Contributor

@jterry75 jterry75 left a comment

Choose a reason for hiding this comment

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

Just a couple of questions. Looks good

{
while (!cancel.IsCancellationRequested)
{
string line = await await Task.WhenAny(reader.ReadLineAsync(), tcs.Task);
Copy link
Contributor

Choose a reason for hiding this comment

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

Alright one last question. When tcs.Task is selected the 2nd await will throw because there is no result. I assume this is the correct behavior and we want to throw the cancellation exception?

Copy link
Contributor Author

@dgvives dgvives Feb 17, 2021

Choose a reason for hiding this comment

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

When cancelling a token I expect to get an OpertionCancelledException. Otherwise it would mean task reached its end instead of being cancelled.

I have no knowledge about architecture or system designs, i just enjoy thinks working on the way I like. Do the minimum to not do it twice.

I've included several tests on this, expecting OperationCancelledException to be thrown and failing test otherwise.
There should be on IContainerOperationsTests

Edited: @jterry75 before removing Stream.Dispose, an ObjectDisposedException would be thrown most of the time, but also IOException and other unexpected behaviours. It took me a while to figure out how to test some parts, in my opinion no changes should be added without tests supporting them. Also there is so much more fun on it

Edit again:
By using (cancel.Register(() => tcs.TrySetCanceled(default))) causes tcs.Task not to be selected because of finished but because of cancelled. Then, OperationCancelledException is thrown.

A different use of TaskCompletionSource is on GetContainerLogs_MonitorStream_Succeeds test, where I validate task result to be what's being set within MonitorStream handler

@galvesribeiro wins

Copy link
Member

Choose a reason for hiding this comment

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

I don't think it will throw there. The tcs.Task will be returned.

In this case I wouldn't assign to the line variable directly. I would assign it to a Task and would check which task is that - the readline or the cancellation.

If that is the cancellation task, then yeah, it is cancelled. If it is the deadline Task, then you string line = await readLineTask();.

Copy link
Contributor Author

@dgvives dgvives Feb 17, 2021

Choose a reason for hiding this comment

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

There should be a test behind this
Edit: @galvesribeiro you win

Copy link
Member

Choose a reason for hiding this comment

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

Agreed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I listened to your advice. It's not exactly as you said but shares the same idea.
It helped fixing some problems when reading streams from containers created without a tty
Thank you @galvesribeiro

Copy link
Contributor

@jterry75 jterry75 left a comment

Choose a reason for hiding this comment

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

LGTM

@jterry75
Copy link
Contributor

@galvesribeiro - Will you check my last question about throwing the cancellation exception here. As long as your good with that we can take this change IMO.

@dgvives
Copy link
Contributor Author

dgvives commented Feb 18, 2021

@jterry75 @galvesribeiro to not merge this. Tested behavior is not right on MonitorStreamAsync

MonitorStreamForMessagesAsync, MonitorResponseForMessagesAsync, and MonitorStreamAsync behave in the same way, throwing TaskCancelledException when task is cancelled.

Expect unified behavior to be pushed soon

@dgvives
Copy link
Contributor Author

dgvives commented Feb 20, 2021

image

Some tests have output details, I used them to understand what I was doing. At some point I gave up but test output details remain

Comment on lines 130 to 131
// @dgvives: This test doesn't work on xUnit test project. Execution never finishes.
// Same code on consoleApp works just fine
Copy link
Contributor

Choose a reason for hiding this comment

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

This may suggest a deadlock bug in the code, rather than an xUnit problem. It's common for async code to work in Console apps but break elsewhere, due to the difference in synchronization contexts. Unfortunately, I'm not an expert in how sync contexts and schedulers and such work, so I don't know where to suggest looking.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I see, I'm creating some sort of web app to test this. Will update with test results

Copy link
Contributor Author

@dgvives dgvives Feb 22, 2021

Choose a reason for hiding this comment

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

@Emdot you are righ, JsonReader.ReadAsync can't be cancelled once serialization has started.

Looking for an alternative now

Comment on lines 351 to 355
if (timeout != s_InfiniteTimeout)
{
using (var timeoutTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
{
timeoutTokenSource.CancelAfter(timeout);
Copy link
Contributor

@Emdot Emdot Feb 21, 2021

Choose a reason for hiding this comment

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

Would it make sense to move the timeout complexity to a separate function? Then this whole method can be:

var request = PrepareRequest(method, path, queryString, headers, data);
return RunWithTimeoutAsync(
    ct => _client.SendAsync(request, completionOption, ct), 
    cancellationToken, timeout);

A RunWithTimeoutAsync implementation might look like this:

public static async Task<T> TimeoutAfter<T>(Func<CancellationToken, Task<T>> makeTask, CancellationToken inputToken, TimeSpan timeout)
{
    // Make a token that triggers either manually or when the input token triggers.
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(inputToken);

    var timerTask = Task.Delay(timeout, cts.Token);
    var mainTask = makeTask(cts.Token);
    
    // Start both the timeout and the main task in parallel.
    Task firstTask == await Task.WhenAny(timerTask, mainTask).ConfigureAwait(false);
    
    // At this point, one of them has finished. Cancel the other one.
    cts.Cancel();
    
    // If the consumer canceled, put the output task in the canceled state and link it to the input token.
    if (inputToken.IsCancellationRequested)
        throw new OperationCanceledException(inputToken);

    // If the timeout occurred first, throw a timed-out exception.
    if (firstTask == timerTask)
        throw new TimeoutException();
    
    // Otherwise return the result of the main task. If it was canceled, this may throw 
    // a cancelation exception. If it faulted, it may throw some other exception.
    return await mainTask.ConfigureAwait(false);
}

For added performance you could use a state parameter to avoid the closure. As a bonus, timeouts emit a separate exception than cancellation.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@Emdot great suggestion.
I'll wait for this to get approved and continue with improvements.

@dgvives
Copy link
Contributor Author

dgvives commented Feb 23, 2021

@jterry75 moving back to stream.Dispose() solved cancellation problems while monitoring streams.
I added a custom handler to have same kind of exception thrown on token cancellation.
Tests have been added to validate failing tasks under different circumstances.

Reason behind this changeset was to fix just DockerClient.Containers.WaitContainerAsync, it should be ready for review.

@dgvives
Copy link
Contributor Author

dgvives commented Feb 24, 2021

Closed while research on stream.read not being cancelled is done

@dgvives
Copy link
Contributor Author

dgvives commented Feb 26, 2021

When using JsonSerializer.Deserialize<T>(JsonReader) to deserialize, deep inside it is calling ExpandoObject.ReadReadObject(JsonReader) and no cancellation is possible from inside the loop, there is no token to cancel.

I went over the source for JsonReader, StreamReader, Stream, and finally JsonSerializer until I found out this.

https://github.com/JamesNK/Newtonsoft.Json/blob/b6dc05be5a0f4808f06ec430f3bb59b24d3fbc3e/Src/Newtonsoft.Json/Converters/ExpandoObjectConverter.cs#L113#L141

        private object ReadObject(JsonReader reader)
        {
            IDictionary<string, object?> expandoObject = new ExpandoObject();

            while (reader.Read())
            {
                switch (reader.TokenType)
                {
                    case JsonToken.PropertyName:
                        string propertyName = reader.Value!.ToString();

                        if (!reader.Read())
                        {
                            throw JsonSerializationException.Create(reader, "Unexpected end when reading ExpandoObject.");
                        }

                        object? v = ReadValue(reader);

                        expandoObject[propertyName] = v;
                        break;
                    case JsonToken.Comment:
                        break;
                    case JsonToken.EndObject:
                        return expandoObject;
                }
            }

            throw JsonSerializationException.Create(reader, "Unexpected end when reading ExpandoObject.");
        }

This isn't easy to find out, was difficult to figure out a solution, and turned even more complicated to test.

@jterry75
Copy link
Contributor

jterry75 commented Mar 2, 2021

@dgvives - Looks like you got it!

@dgvives
Copy link
Contributor Author

dgvives commented Mar 3, 2021

@jterry75 - Yes, it has been a very interesting journey into task cancelling and tests

@jterry75
Copy link
Contributor

jterry75 commented Mar 3, 2021

Do you want to rebase all your commits into one just because it was a lot of change over them and then I'll do a final review and signoff. But looks good to me

@dgvives dgvives force-pushed the bugfix/cancellation-token-not-cancelling branch from 974e624 to f0336bf Compare March 3, 2021 18:32
@dgvives
Copy link
Contributor Author

dgvives commented Mar 5, 2021

@jterry75 done

Copy link
Contributor

@jterry75 jterry75 left a comment

Choose a reason for hiding this comment

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

LGTM

@jterry75
Copy link
Contributor

jterry75 commented Mar 5, 2021

@galvesribeiro - Want to take a look as well

Copy link
Member

@galvesribeiro galvesribeiro left a comment

Choose a reason for hiding this comment

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

Awesome work! Thank you for the contribution and the huge patience across the reviews.

@galvesribeiro galvesribeiro merged commit c0bca61 into dotnet:master Mar 5, 2021
@galvesribeiro
Copy link
Member

@jterry75 do you think we have enough for a new release now?

@galvesribeiro
Copy link
Member

@jterry75 ?

@dgvives dgvives deleted the bugfix/cancellation-token-not-cancelling branch May 16, 2022 14:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
4 participants