Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ internal TaskAsyncResult(Task task, object? state, AsyncCallback? callback)
// The task has already completed. Treat this as synchronous completion.
// Invoke the callback; no need to store it.
CompletedSynchronously = true;
// Observe any fault so that UnobservedTaskException is not raised if End
// is never called. In the APM pattern exceptions are propagated via End;
// End will still rethrow the exception if called.
_ = task.Exception;
callback?.Invoke(this);
}
else if (callback is not null)
Expand All @@ -138,7 +142,14 @@ internal TaskAsyncResult(Task task, object? state, AsyncCallback? callback)
_callback = callback;
_task.ConfigureAwait(continueOnCapturedContext: false)
.GetAwaiter()
.OnCompleted(() => _callback.Invoke(this));
.OnCompleted(() =>
{
// Observe any fault so that UnobservedTaskException is not raised if End
// is never called. In the APM pattern exceptions are propagated via End;
// End will still rethrow the exception if called.
_ = _task.Exception;
_callback.Invoke(this);
});
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,75 @@ public async Task WithFromAsync_Delegate_Roundtrips()
tcs.SetResult();
await invoked.Task;
}

[Fact]
public async Task FaultedTask_CallbackDoesNotCallEnd_NoUnobservedTaskException_Async()
{
// Regression test: if the APM callback does not call End, a faulted task must not
// surface via TaskScheduler.UnobservedTaskException. In the APM pattern, exceptions
// are only propagated through End; skipping End (e.g. during shutdown) must be silent.
bool unobservedFired = false;
EventHandler<UnobservedTaskExceptionEventArgs> handler = (s, e) => unobservedFired = true;
TaskScheduler.UnobservedTaskException += handler;
try
{
var tcs = new TaskCompletionSource<int>();

// Callback intentionally does not call End.
WeakReference weakRef = await Task.Run(() =>
{
IAsyncResult ar = TaskToAsyncResult.Begin(tcs.Task, _ => { /* no End */ }, null);
tcs.SetException(new InvalidOperationException("test fault"));
return new WeakReference(ar);
});

// Allow the callback continuation to complete, then drop all references.
await Task.Delay(100);

for (int i = 0; i < 5 && weakRef.IsAlive; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
await Task.Delay(100);
}

Assert.False(unobservedFired, "UnobservedTaskException should not fire when End is not called in APM callback");
}
finally
{
TaskScheduler.UnobservedTaskException -= handler;
}
}

[Fact]
public void FaultedTask_AlreadyCompleted_CallbackDoesNotCallEnd_NoUnobservedTaskException_Sync()
{
// Same regression but for the synchronous-completion path (task already faulted
// when Begin is called, callback fires inline).
bool unobservedFired = false;
EventHandler<UnobservedTaskExceptionEventArgs> handler = (s, e) => unobservedFired = true;
TaskScheduler.UnobservedTaskException += handler;
try
{
Task<int> faulted = Task.FromException<int>(new InvalidOperationException("test fault"));

// Callback intentionally does not call End.
IAsyncResult ar = TaskToAsyncResult.Begin(faulted, _ => { /* no End */ }, null);
Assert.True(ar.CompletedSynchronously);

ar = null;
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();

Assert.False(unobservedFired, "UnobservedTaskException should not fire when End is not called in APM callback");
}
finally
{
TaskScheduler.UnobservedTaskException -= handler;
}
}
}

internal sealed class NonTaskIAsyncResult : IAsyncResult
Expand Down
Loading