Skip to content

Control.BeginInvoke completion throws ObjectDisposedException when AsyncWaitHandle is disposed #14996

Description

@jaywang-cn

.NET version

.NET 10.0.8 and current dotnet/winforms main.

Did it work in .NET Framework?

Not tested/verified.

Did it work in any of the earlier releases of .NET Core or .NET 5+?

The deterministic repro below is based on the current main branch. The underlying IAsyncResult.AsyncWaitHandle exposure and unguarded completion signal also exist in earlier implementations, so this is not being reported as a confirmed release regression.

Issue description

Control.BeginInvoke returns a ThreadMethodEntry as IAsyncResult. Accessing IAsyncResult.AsyncWaitHandle exposes the internal ManualResetEvent used by ThreadMethodEntry.Complete().

If the consumer disposes that wait handle before the marshaled callback completes, the callback itself runs, but completion then throws on the UI thread:

System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'Microsoft.Win32.SafeHandles.SafeWaitHandle'.
   at Interop.Kernel32.SetEvent(SafeWaitHandle handle)
   at System.Threading.EventWaitHandle.Set()
   at System.Windows.Forms.Control.InvokeMarshaledCallbacks()

ThreadMethodEntry.Complete() currently calls _resetEvent?.Set() without handling the fact that the same event is publicly exposed and may already have been disposed:

https://github.com/dotnet/winforms/blob/main/src/System.Windows.Forms/System/Windows/Forms/Control.ThreadMethodEntry.cs

This is similar to dotnet/runtime#64400. dotnet/runtime#64627 hardened task completion against an externally disposed completion wait handle by tolerating ObjectDisposedException from Set().

There is also a framework-owned lifetime path worth reviewing. Since #10460, synchronous Control.Invoke disposes tme.AsyncWaitHandle with using after WaitForWaitHandle() returns. WaitForWaitHandle() has paths that return without observing the handle as signaled, such as when Application.ThreadContext.FromId() returns null. If one of those paths is reachable while the callback remains queued, the framework could produce the same failure without application code accessing AsyncWaitHandle. I have not independently reproduced that path, so it is not part of the deterministic repro below.

Steps to reproduce

Add this test to ControlTests.Methods.cs:

[WinFormsFact]
public void Control_BeginInvoke_DisposedAsyncWaitHandle_CompletesCallback()
{
    using Control control = new();
    Assert.NotEqual(IntPtr.Zero, control.Handle);
    bool callbackInvoked = false;
    IAsyncResult asyncResult = control.BeginInvoke(() => callbackInvoked = true);
    asyncResult.AsyncWaitHandle.Dispose();

    control.TestAccessor().Dynamic.InvokeMarshaledCallbacks();

    Assert.True(callbackInvoked);
    Assert.True(asyncResult.IsCompleted);
}

On current main, the call to InvokeMarshaledCallbacks() fails with the ObjectDisposedException shown above. The callback has already executed when the completion signal throws.

Expected behavior: completing a marshaled callback should not crash the UI thread solely because the publicly exposed completion wait handle was disposed.

A minimal fix is to tolerate ObjectDisposedException when ThreadMethodEntry.Complete() signals _resetEvent, consistent with dotnet/runtime#64627. A regression test should verify both callback execution and IsCompleted.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

🪲 bugProduct bug (most likely)

Type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions