Add cancellation handling to prevent UnobservedTaskException in Watcher#1827
Add cancellation handling to prevent UnobservedTaskException in Watcher#1827tg123 wants to merge 4 commits into
Conversation
|
[APPROVALNOTIFIER] This PR is APPROVED This pull-request has been approved by: tg123 The full list of commands accepted by this bot can be found here. The pull request process is described here DetailsNeeds approval from an approver in each of these files:
Approvers can indicate their approval by writing |
There was a problem hiding this comment.
Pull request overview
This PR fixes a watch-cancellation edge case where cancelling while an async read is in-flight can orphan the original task and later surface as TaskScheduler.UnobservedTaskException during finalization/shutdown.
Changes:
- Update
Watcher<T>.CreateWatchEventEnumeratorto ensure faulted orphan tasks are observed, preventingUnobservedTaskException. - On newer TFMs, pass the
CancellationTokendirectly intoReadLineAsync(...)to cancel the read itself. - Add a regression test that reproduces the orphaned-task scenario and asserts no
UnobservedTaskExceptionis raised.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
src/KubernetesClient/Watcher.cs |
Ensures faulted orphan tasks are observed; uses cancellation-aware read on newer TFMs. |
tests/KubernetesClient.Tests/WatchTests.cs |
Adds regression coverage to detect UnobservedTaskException during watch cancellation/fault scenarios. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| var unobservedExceptions = new List<Exception>(); | ||
| void Handler(object sender, UnobservedTaskExceptionEventArgs e) | ||
| { | ||
| unobservedExceptions.Add(e.Exception); | ||
| } |
| await Task.Delay(50).ConfigureAwait(true); | ||
| } | ||
|
|
||
| Assert.Empty(unobservedExceptions); |
…ync reading with cancellation support
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
tests/KubernetesClient.Tests/WatchTests.cs:1044
TaskScheduler.UnobservedTaskExceptionis raised on the finalizer thread, so this handler can be invoked concurrently with the test thread. UsingList<Exception>here is not thread-safe, and not callinge.SetObserved()can allow the runtime to escalate the unobserved exception (environment-dependent), making the test flaky or terminating the process.
var unobservedExceptions = new List<Exception>();
void Handler(object sender, UnobservedTaskExceptionEventArgs e)
{
unobservedExceptions.Add(e.Exception);
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
tests/KubernetesClient.Tests/WatchTests.cs:1044
TaskScheduler.UnobservedTaskExceptionis raised on the finalizer thread; mutating aList<Exception>from that handler is not thread-safe. Also, if an exception does occur, not callinge.SetObserved()can destabilize the test run depending on runtime settings. Prefer a thread-safe collection and mark the exception as observed while still asserting that the queue stays empty.
var unobservedExceptions = new List<Exception>();
void Handler(object sender, UnobservedTaskExceptionEventArgs e)
{
unobservedExceptions.Add(e.Exception);
}
| if (cancellationToken.IsCancellationRequested) | ||
| { | ||
| return Task.FromCanceled<TResult>(cancellationToken); | ||
| } | ||
|
|
||
| // Observe any exception from the original task to prevent an | ||
| // UnobservedTaskException when the continuation below is cancelled | ||
| // before the original task faults (e.g. the transport tears down the | ||
| // connection after cancellation). | ||
| _ = task.ContinueWith( | ||
| static t => { _ = t.Exception; }, | ||
| CancellationToken.None, | ||
| TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, | ||
| TaskScheduler.Default); | ||
|
|
||
| // here to pass cancellationToken into task | ||
| return task.ContinueWith(t => t.GetAwaiter().GetResult(), cancellationToken); | ||
| } |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (3)
tests/KubernetesClient.Tests/WatchTests.cs:1044
- The
UnobservedTaskExceptionhandler mutates aList<Exception>from the finalizer thread, which is a data race with the test thread's laterAssert.Empty. Also, if this event fires unexpectedly, not callinge.SetObserved()can still impact the process under some configurations.
var unobservedExceptions = new List<Exception>();
void Handler(object sender, UnobservedTaskExceptionEventArgs e)
{
unobservedExceptions.Add(e.Exception);
}
src/KubernetesClient.Classic/CancellationExtensions.cs:40
WaitAsyncpolyfill usesContinueWith(...)without specifyingTaskScheduler.Default/ExecuteSynchronously. This can capture a caller's current scheduler (e.g., UI/ASP.NET sync context) and run the continuation there, which is surprising for a general-purpose polyfill and can contribute to deadlocks.
// here to pass cancellationToken into task
return task.ContinueWith(t => t.GetAwaiter().GetResult(), cancellationToken);
src/KubernetesClient.Classic/CancellationExtensions.cs:27
WaitAsyncreturns a canceled task immediately whencancellationToken.IsCancellationRequested, but in that branch it never observes exceptions from the originaltask. If the original task later faults, this can still surface asTaskScheduler.UnobservedTaskException—the exact scenario this helper is meant to prevent.
if (cancellationToken.IsCancellationRequested)
{
return Task.FromCanceled<TResult>(cancellationToken);
}
|
|
||
| <Compile Include="..\KubernetesClient\Watcher.cs" /> | ||
| <Compile Include="..\KubernetesClient\WatcherExt.cs" /> | ||
| <Compile Include="..\KubernetesClient\LineSeparatedHttpContent.cs" /> | ||
|
|
| using var streamReader = await streamReaderCreator().WaitAsync(cancellationToken).ConfigureAwait(false); | ||
|
|
||
| for (; ; ) | ||
| { | ||
| // ReadLineAsync will return null when we've reached the end of the stream. | ||
| var line = await AttachCancellationToken(streamReader.ReadLineAsync()).ConfigureAwait(false); | ||
| var line = await streamReader.ReadLineAsync(cancellationToken).ConfigureAwait(false); |
follow up copilot ver #1814
fix #1813
Cancelling a watch's CancellationToken while ReadLineAsync() is in flight left the original read task orphaned. When the transport tore the connection down, that task faulted with an unobserved IOException, surfacing as a TaskScheduler.UnobservedTaskException at finalization (e.g. during app shutdown).
Root cause: AttachCancellationToken in CreateWatchEventEnumerator wraps the read via task.ContinueWith(..., cancellationToken). On cancellation the continuation is cancelled immediately while the original ReadLineAsync() keeps running and later faults — with no one observing it.