dotnet / dotnet/winforms

Preserve logical ExecutionContext when marshaling callbacks with Control.Invoke/BeginInvoke

Open
#15,068 1 comment 0 reactions 0 assignees View on GitHub
api-suggestion untriaged
Dominant language
C#
Stars
4.9k
Forks
1.1k
Avg merge
20h 23m
Merged PRs (30d)
103

Description

### Background and motivation

## Problem

Windows Forms provides `Control.Invoke` and `Control.BeginInvoke` to marshal callbacks from a worker thread to the thread that owns a control.

These APIs correctly solve the UI-thread-affinity problem, but they do not provide an explicit mechanism for preserving the caller's logical `ExecutionContext` when crossing the WinForms dispatch boundary.

This becomes problematic for applications that use `AsyncLocal` and `ExecutionContext` to maintain logical application state such as:

* correlation IDs
* user/session context
* request/journey IDs
* diagnostic context
* tracing information
* other ambient state

For example:

```csharp
static readonly AsyncLocal JourneyId = new();

async Task DoWorkAsync(Control control)
{
JourneyId.Value = "journey-123";

await Task.Run(() =>
{
control.BeginInvoke(() =>
{
Console.WriteLine(JourneyId.Value);
});
});
}
```

The callback is marshaled to the UI thread, but applications cannot rely on the caller's logical `ExecutionContext` being available there.

As a result, applications that depend on ambient logical context need to implement their own capture/restore mechanism:

```csharp
var context = ExecutionContext.Capture();

control.BeginInvoke(() =>
{
if (context != null)
{
ExecutionContext.Run(
context,
_ => DoWork(),
null);
}
else
{
DoWork();
}
});
```

This is repetitive, easy to get wrong, and particularly problematic in large existing WinForms applications where `Invoke`/`BeginInvoke` is used in many locations.

## Why this matters

UI marshaling is a logical continuation of the operation that requested the marshaling.

For example, consider an application tracking a user's journey:

```text
User action
|
| JourneyId = ABC123
|
+--> asynchronous operation
|
+--> Control.BeginInvoke(...)
|
+--> UI callback
```

From the application's perspective, the UI callback is still part of the same logical operation.

However, without explicitly preserving the execution context, the application may observe:

```text
JourneyId = ABC123
|
| BeginInvoke
v
JourneyId = null
```

The application therefore has to implement infrastructure around dispatcher calls merely to preserve its logical state.

This is especially relevant for modern .NET applications using `AsyncLocal`, `Activity.Current`, logging scopes, and telemetry/correlation mechanisms.

More importantly, not every `Invoke`/`BeginInvoke` call is necessarily under the application's control.

A WinForms application may use third-party components that perform UI marshaling internally. For example, a dependency may internally call:

```csharp
control.BeginInvoke(() =>
{
// UI operation
});
```

without exposing any way for the consuming application to change that call.

A per-call context-preserving API would not solve this situation because the application cannot modify the invocation performed by the dependency.

The application therefore needs a way to opt into context preservation at the boundary of its logical operation rather than at every individual invocation call site.

## Proposed behavior

I am not proposing that `Control.Invoke`/`BeginInvoke` should unconditionally capture and propagate `ExecutionContext` for every call, as this could introduce compatibility and performance concerns.

Instead, the context-preservation behavior should be controlled by an **opt-in ambient mechanism** that can be enabled for a logical async operation.

One possible API shape is a scoped mechanism such as:

```csharp
using (Control.EnableExecutionContextFlow())
{
// Application's logical operation
}
```

When enabled, `Control.Invoke`, `Control.BeginInvoke`, and `Control.InvokeAsync` would preserve the caller's logical `ExecutionContext` when the callback is marshaled to the UI thread.

The mechanism should be opt-in and default to disabled, preserving the existing behavior for applications that do not require this functionality.

The ambient aspect is important because applications do not necessarily control every `Invoke`/`BeginInvoke` call site.

For example:

```csharp
using (Control.EnableExecutionContextFlow())
{
await thirdPartyComponent.PerformOperationAsync();
}
```

If the third-party component internally performs:

```csharp
control.BeginInvoke(() =>
{
// UI operation
});
```

the context-preservation behavior would already be enabled for the surrounding logical operation.

This avoids requiring the third-party component to be modified or to use a new context-preserving API.

A static opt-in property could also be considered, for example:

```csharp
Control.FlowExecutionContext = true;
```

with `false` as the default.

However, a scoped API such as `EnableExecutionContextFlow()` may be preferable because it avoids mutable global state and allows applications to limit context propagation to specific logical operations.

The exact API shape and naming are open to discussion. The key requirement is an **opt-in, ambient mechanism for preserving the caller's logical `ExecutionContext` across WinForms UI dispatch boundaries**.

## Real-world use case

I am working on a WinForms application where we track a user's journey through the application.

The journey context is stored using ambient logical state and needs to survive transitions such as:

```text
UI event
-> async operation
-> background thread
-> Control.BeginInvoke
-> UI callback
-> additional async operation
```

Because the application contains many existing `Invoke`/`BeginInvoke` calls, we currently have to capture and restore the `ExecutionContext` ourselves around dispatcher calls.

For example:

```csharp
var context = ExecutionContext.Capture();

dispatcher.BeginInvoke(() =>
{
ExecutionContext.Run(
context!,
_ => action(),
null);
});
```

This works as an application-level workaround, but it is infrastructure that would be useful for the framework to provide.

There is an additional problem when the invocation originates from a third-party component.

A dependency may perform UI marshaling internally:

```csharp
control.BeginInvoke(() =>
{
// UI operation
});
```

The consuming application may have no ability to replace that call with a custom context-preserving API.

An ambient opt-in mechanism would allow the application to establish the desired context-flow behavior around its logical operation without requiring third-party components to be aware of the application's context-tracking requirements.

## Expected benefit

An opt-in, ambient context-preservation mechanism would make it easier for WinForms applications to correctly implement:

* user journey tracking
* correlation IDs
* diagnostic logging
* `AsyncLocal`-based application context
* tracing
* logging scopes
* distributed/telemetry correlation
* other logical ambient state

without requiring every application to implement its own dispatcher wrapper.

It would also allow applications to preserve logical context across UI dispatch performed by third-party WinForms components that the application does not control.

The default behavior would remain unchanged, so applications that do not opt in would not be affected.

## Question for the WinForms team

Is the absence of `ExecutionContext` preservation across `Control.Invoke`/`BeginInvoke` intentional?

If so, would the team consider providing an **opt-in ambient mechanism** for preserving the caller's logical `ExecutionContext` across WinForms UI dispatch boundaries, particularly alongside `Control.InvokeAsync`?

One possible API shape is:

```csharp
using (Control.EnableExecutionContextFlow())
{
// logical operation
}
```

The exact API design and naming are open for discussion.

The important aspect is that the opt-in would be ambient to the logical operation rather than requiring an option to be passed to every individual `Invoke`/`BeginInvoke` call.

This is particularly important for applications that use third-party WinForms components, because those components may perform `Invoke`/`BeginInvoke` internally without giving the consuming application control over the individual invocation.

I would also appreciate guidance on the recommended framework-supported pattern for applications that need to preserve `AsyncLocal`/logical execution context when crossing the WinForms UI dispatch boundary, particularly when the invocation originates from third-party components.

### API Proposal

Add an opt-in ambient mechanism that controls whether WinForms UI marshaling preserves the caller's logical `ExecutionContext`.

One possible API shape is:

```csharp
public static IDisposable EnableExecutionContextFlow();
```

Usage:

```csharp
using (Control.EnableExecutionContextFlow())
{
await DoWorkAsync();
}
```

While the flow is enabled, `Control.Invoke`, `Control.BeginInvoke`, and `Control.InvokeAsync` would preserve the caller's `ExecutionContext` when dispatching the callback to the UI thread.

The opt-in should be ambient to the logical async flow rather than requiring an option to be passed to every individual invocation.

This is important for applications that use third-party WinForms components. A third-party component may internally call:

```csharp
control.BeginInvoke(...);
```

without exposing any way for the consuming application to change that call.

With an ambient opt-in mechanism, the consuming application could enable context flow around its logical operation without requiring the third-party component to be changed.

An alternative API shape could be a static property:

```csharp
Control.FlowExecutionContext = true;
```

with `false` as the default.

A scoped API may be preferable to a global mutable property because it allows applications to explicitly control the lifetime of the behavior and avoids changing context-flow behavior for unrelated operations.

The exact API shape and naming are open for discussion. The important requirement is the **opt-in ambient behavior**, rather than a particular API name or signature.

### API Usage

A typical use case is propagating a logical user/session/journey context stored in `AsyncLocal`.

```csharp
static readonly AsyncLocal JourneyId = new();

async Task DoWorkAsync(Control control)
{
using (Control.EnableExecutionContextFlow())
{
JourneyId.Value = "journey-123";

await Task.Run(() =>
{
// This could be application code or code inside
// a third-party component.

control.BeginInvoke(() =>
{
// JourneyId.Value == "journey-123"
UpdateUI();
});
});
}
}
```

The important part is that the caller does not need to change the individual `BeginInvoke` call.

This also allows the mechanism to work with third-party WinForms components:

```csharp
using (Control.EnableExecutionContextFlow())
{
await thirdPartyComponent.PerformOperationAsync();
}
```

If the third-party component internally performs:

```csharp
control.BeginInvoke(() =>
{
// UI operation
});
```

the application's logical `ExecutionContext` would still be available to that callback.

Without framework support, applications currently need to implement their own capture/restore mechanism:

```csharp
var context = ExecutionContext.Capture();

control.BeginInvoke(() =>
{
ExecutionContext.Run(
context!,
_ => UpdateUI(),
null);
});
```

This becomes particularly difficult when the invocation originates from a dependency that the application does not control.

An ambient, opt-in mechanism would allow the application to establish the desired context-flow behavior without requiring changes to every invocation call site or to third-party dependencies.

### Alternative Designs

**Per-call context-preserving API**

A per-call API such as:

```csharp
control.BeginInvokePreserveContext(...)
```

was considered.

However, this does not solve the problem when `Invoke`/`BeginInvoke` is called by third-party WinForms components that the application does not control.

An ambient opt-in mechanism allows the consuming application to establish the desired context-flow behavior without requiring every invocation call site, or every dependency, to be modified.

**Static global property**

A static property such as:

```csharp
Control.FlowExecutionContext = true;
```

could provide a simple opt-in mechanism.

However, a scoped API may be preferable because it avoids mutable global state and allows applications to limit context propagation to specific logical operations.

**Explicit `ExecutionContext` parameter**

Another possibility would be to explicitly capture and pass an `ExecutionContext` to the invocation API.

While this makes the behavior explicit, it still requires every invocation call site to be modified and therefore has the same limitation with third-party components.

For this use case, an ambient opt-in mechanism is preferable because the application can establish the desired behavior at the boundary of its logical operation rather than at every individual dispatch call.

### Risks

Automatically flowing `ExecutionContext` for all existing `Invoke`/`BeginInvoke` calls could introduce performance overhead and potentially change existing application behavior.

For this reason, the proposed behavior is explicitly opt-in and should default to disabled. Existing applications would therefore retain the current behavior unless they explicitly enable context propagation.

There may also be concerns about the size and contents of `ExecutionContext`, since it can contain more than application-specific `AsyncLocal` state.

The proposed API should therefore use the existing .NET `ExecutionContext` semantics rather than introducing a separate or incompatible context-propagation mechanism.

A scoped opt-in would also limit the behavior to logical operations that explicitly request it, reducing the risk of unintentionally changing unrelated UI dispatch operations.

### Alternatives

**Application-level wrapper**

Applications can create their own wrapper around `Control.Invoke`/`BeginInvoke` that captures and restores `ExecutionContext`.

This works for application-owned code, but it requires custom infrastructure and can easily be bypassed at individual call sites.

It also does not fully solve the problem when `Invoke`/`BeginInvoke` is called internally by a third-party WinForms component that the application does not control.

**Per-call context-preserving API**

An API such as:

```csharp
control.BeginInvokePreserveContext(...);
```

would make context propagation explicit, but has the same limitation: the application must control the invocation call site.

This is why an ambient opt-in mechanism is preferred for this scenario.

**`AsyncLocal`**

`AsyncLocal` is appropriate for storing logical ambient state, but it does not itself determine how that state should be propagated across a WinForms UI dispatch boundary.

**`SynchronizationContext`**

`SynchronizationContext` provides a mechanism for marshaling work to the UI thread, but it does not by itself provide an opt-in mechanism for transferring arbitrary caller `ExecutionContext` when using WinForms control invocation APIs.

**`Activity` / OpenTelemetry**

`Activity` is a good solution for tracing and correlation, but it does not replace arbitrary application-specific logical context stored using `AsyncLocal`.

Therefore, an opt-in ambient mechanism at the WinForms dispatch boundary would provide a framework-level solution while preserving existing behavior and allowing applications to work with third-party components they cannot modify.

### Will this feature affect UI controls?

No. The proposal does not change the behavior, properties, rendering, layout, or lifecycle of UI controls.

It only affects how the execution context is propagated when an existing callback is marshaled to a control's owning UI thread.

Contributor guide

Open the contributing guide

Research direction

Read the existing Control.Invoke, Control.BeginInvoke, and Control.InvokeAsync paths first, focusing on how callbacks cross the dispatch boundary and how ExecutionContext currently behaves. Compare the scoped and static-property proposals, then define an opt-in ambient design that preserves existing default behavior and works for third-party calls. Done means an agreed API and behavior for AsyncLocal/ExecutionContext propagation, including the default-disabled case.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
desktop
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.