From 2e69e3c58d111a9db2a2a325d089a28b5a28548e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 07:13:58 +0000 Subject: [PATCH 1/3] Initial plan From ee7fd35cb96df9416d5056324a15f8c943dadf1a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 07:18:01 +0000 Subject: [PATCH 2/3] Fix UnobservedTaskException in Watcher cancellation; use net7+ ReadLineAsync(token) --- src/KubernetesClient/Watcher.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/KubernetesClient/Watcher.cs b/src/KubernetesClient/Watcher.cs index 23868d4e0..c475908a6 100644 --- a/src/KubernetesClient/Watcher.cs +++ b/src/KubernetesClient/Watcher.cs @@ -162,6 +162,16 @@ Task AttachCancellationToken(Task task) { if (!task.IsCompleted) { + // 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); } @@ -174,7 +184,11 @@ Task AttachCancellationToken(Task task) for (; ; ) { // ReadLineAsync will return null when we've reached the end of the stream. +#if NET7_0_OR_GREATER + var line = await streamReader.ReadLineAsync(cancellationToken).ConfigureAwait(false); +#else var line = await AttachCancellationToken(streamReader.ReadLineAsync()).ConfigureAwait(false); +#endif cancellationToken.ThrowIfCancellationRequested(); From a3a4c9f88ca0a004b3fe675e677a261ab99451f9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 07:55:07 +0000 Subject: [PATCH 3/3] Add regression test for UnobservedTaskException on watch cancellation --- tests/KubernetesClient.Tests/WatchTests.cs | 65 ++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/KubernetesClient.Tests/WatchTests.cs b/tests/KubernetesClient.Tests/WatchTests.cs index 53259d770..fe314b3c6 100644 --- a/tests/KubernetesClient.Tests/WatchTests.cs +++ b/tests/KubernetesClient.Tests/WatchTests.cs @@ -1028,5 +1028,70 @@ public async Task AsyncEnumerableWatchErrorHandling() Assert.True(watchCompleted.IsSet); } } + + [Fact] + public async Task CancellationDoesNotLeaveUnobservedTaskException() + { + // Regression test for https://github.com/kubernetes-client/csharp/issues/1813 + // When the cancellation token is cancelled while a read is in flight and the + // underlying task subsequently faults (e.g. transport-level IOException after the + // connection is torn down), the faulting task must be observed so that no + // TaskScheduler.UnobservedTaskException is raised when it is finalized. + var unobservedExceptions = new List(); + void Handler(object sender, UnobservedTaskExceptionEventArgs e) + { + unobservedExceptions.Add(e.Exception); + } + + TaskScheduler.UnobservedTaskException += Handler; + try + { + // Run the cancellation scenario in a separate, non-inlined method so that all + // references to the orphaned task go out of scope before we force a collection. + await RunCancelledWatchAsync().ConfigureAwait(true); + + // Force the orphaned task to be finalized; without observing its exception this + // would raise TaskScheduler.UnobservedTaskException. + for (var i = 0; i < 5; i++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + await Task.Delay(50).ConfigureAwait(true); + } + + Assert.Empty(unobservedExceptions); + } + finally + { + TaskScheduler.UnobservedTaskException -= Handler; + } + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async Task RunCancelledWatchAsync() + { + using var cts = new CancellationTokenSource(); + var faultReader = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + Func> streamReaderCreator = () => faultReader.Task; + + var enumerator = Watcher + .CreateWatchEventEnumerator(streamReaderCreator, onError: null, cancellationToken: cts.Token) + .GetAsyncEnumerator(cts.Token); + + // Start the enumeration; this awaits the (not yet completed) creator task. + var moveNext = enumerator.MoveNextAsync(); + + // Cancel before the creator task completes. The cancellation-aware continuation + // is cancelled, but the original creator task is still pending. + cts.Cancel(); + + await Assert.ThrowsAnyAsync(async () => await moveNext.ConfigureAwait(true)).ConfigureAwait(true); + + await enumerator.DisposeAsync().ConfigureAwait(true); + + // Now fault the original creator task, mimicking the transport tear-down. + faultReader.SetException(new IOException("The request was aborted.")); + } } }