From 0ddaa8d454c58ed38cb3b6774e4a335522c765eb Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Sat, 7 Mar 2026 14:32:11 -0800 Subject: [PATCH 1/8] Add Windows Runtime event infrastructure doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive documentation for CsWinRT's event infrastructure under docs/event-infrastructure.md. Covers core types (EventRegistrationToken, EventSource, EventSourceState, EventSourceCache, EventRegistrationTokenTable), RCW (managed→native) and CCW (native→managed) event flows, lifetime and GC semantics (including reference tracking and cleanup), and an in-depth analysis of static event lifetime issues and potential fixes. Intended to help maintainers and contributors understand event registration, marshalling, and cross-context pitfalls. --- docs/event-infrastructure.md | 607 +++++++++++++++++++++++++++++++++++ 1 file changed, 607 insertions(+) create mode 100644 docs/event-infrastructure.md diff --git a/docs/event-infrastructure.md b/docs/event-infrastructure.md new file mode 100644 index 0000000000..01a0b284d4 --- /dev/null +++ b/docs/event-infrastructure.md @@ -0,0 +1,607 @@ +# Windows Runtime Event Infrastructure + +This document provides a deep dive into how CsWinRT's event infrastructure works in `WinRT.Runtime2`. It covers event sources, event states, the event source cache, event registration tokens, RCW/CCW interaction, lifetime management across the native ABI boundary, and the specific behavior of static events. + +## Table of Contents + +- [Overview](#overview) +- [Core Types](#core-types) + - [EventRegistrationToken](#eventregistrationtoken) + - [EventSource\](#eventsourcet) + - [EventSourceState\](#eventsourcestatet) + - [EventSourceCache](#eventsourcecache) + - [EventRegistrationTokenTable\](#eventregistrationtokentablet) +- [Instance Events (RCW → Native)](#instance-events-rcw--native) + - [Subscribe Flow](#subscribe-flow) + - [Unsubscribe Flow](#unsubscribe-flow) + - [Object Diagrams](#object-diagrams) +- [CCW Events (Native → Managed)](#ccw-events-native--managed) +- [Lifetime and GC](#lifetime-and-gc) + - [What Keeps What Alive](#what-keeps-what-alive) + - [EventInvoke Delegate and the CCW](#eventinvoke-delegate-and-the-ccw) + - [Reference Tracking Integration](#reference-tracking-integration) + - [Cleanup on GC](#cleanup-on-gc) +- [Static Events](#static-events) + - [How Static Events Differ from Instance Events](#how-static-events-differ-from-instance-events) + - [The Activation Factory Cache](#the-activation-factory-cache) + - [Context Switches and the IsInCurrentContext Check](#context-switches-and-the-isincurrentcontext-check) + - [Static Event Lifecycle Analysis](#static-event-lifecycle-analysis) + +--- + +## Overview + +Windows Runtime events follow a pattern where subscribing to an event yields an `EventRegistrationToken`, and that same token must be passed back to unsubscribe. CsWinRT bridges this model to the .NET `event += handler` / `event -= handler` pattern, maintaining a parallel set of infrastructure that: + +1. **Wraps native COM event subscriptions** (for managed code consuming native events — the RCW path). +2. **Exposes managed events to native callers** (for native code consuming managed events — the CCW path). + +These two paths use different core types: + +| Path | Direction | Core Types | +|------|-----------|-----------| +| **RCW events** | Managed subscribes to a native event | `EventSource`, `EventSourceState`, `EventSourceCache` | +| **CCW events** | Native subscribes to a managed event | `EventRegistrationTokenTable` | + +--- + +## Core Types + +### EventRegistrationToken + +**File:** `InteropServices/Events/EventRegistrationToken.cs` + +A simple value type wrapping a 64-bit integer. This is the Windows Runtime's concept of a "subscription handle" — you get one from `add_EventName` and pass it to `remove_EventName` to unsubscribe. + +```csharp +public struct EventRegistrationToken : IEquatable +{ + public long Value { get; set; } +} +``` + +### EventSource\ + +**File:** `InteropServices/Events/EventSource{T}.cs` + +The main entry point for managed code subscribing to a native Windows Runtime event. Each `EventSource` wraps a specific event on a specific native object. It: + +- Holds a strong reference to the `WindowsRuntimeObjectReference` for the native object. +- Maintains a `WeakReference` pointing to an `EventSourceState` (which holds the actual registration state). +- Coordinates `Subscribe` / `Unsubscribe` operations, including marshalling calls to the native vtable. +- Uses a vtable **index** to find `add_EventName` and `remove_EventName` — these are always at consecutive vtable slots (`[Index]` and `[Index + 1]`). + +**Key design choice:** The `EventSource` itself doesn't directly hold the `EventRegistrationToken` or the combined delegate. Those live in the `EventSourceState`, which is kept alive by the native side through a CCW. This separation ensures event registrations survive garbage collection of the `EventSource` object. + +Concrete derived types exist for each delegate shape: + +| Type | Delegate Type | +|------|--------------| +| `EventHandlerEventSource` | `EventHandler` | +| `EventHandlerEventSource` | `EventHandler` | +| `EventHandlerEventSource` | `EventHandler` | +| `PropertyChangedEventHandlerEventSource` | `PropertyChangedEventHandler` | +| `NotifyCollectionChangedEventHandlerEventSource` | `NotifyCollectionChangedEventHandler` | +| `VectorChangedEventHandlerEventSource` | `VectorChangedEventHandler` | +| `MapChangedEventHandlerEventSource` | `MapChangedEventHandler` | + +Each derived type implements two abstract methods: +- `ConvertToUnmanaged(T handler)` — marshals the delegate to a native CCW. +- `CreateEventSourceState()` — creates the concrete `EventSourceState` subclass. + +### EventSourceState\ + +**File:** `InteropServices/Events/EventSourceState{T}.cs` + +Holds all the mutable state for a single native event registration: + +| Field | Purpose | +|-------|---------| +| `void* _thisPtr` | The native pointer for the event source object (used only as a cache key — never dereferenced). | +| `int _index` | The event's vtable index. | +| `WeakReference _weakReferenceToSelf` | Used in `EventSourceCache` to allow weak tracking of this state object. | +| `void* _eventInvokePtr` | Pointer to the CCW for the `EventInvoke` delegate. | +| `void* _referenceTrackerTargetPtr` | IReferenceTrackerTarget on the CCW (for XAML reference tracking). | +| `T? TargetDelegate` | The combined multicast delegate of all managed handlers currently subscribed. | +| `T EventInvoke` | The delegate instance registered with the native event. This captures `this` (the state). | +| `EventRegistrationToken Token` | The token returned from the native `add_EventName` call. | + +The `EventInvoke` delegate is the central piece of the design. It is a delegate that: +1. Captures `this` (the `EventSourceState` instance), keeping the state alive. +2. When invoked, calls `TargetDelegate?.Invoke(...)` to forward to all managed subscribers. +3. Is marshalled into a CCW and passed to the native `add_EventName` call. + +Each concrete state creates its `EventInvoke` via `GetEventInvoke()`: + +```csharp +protected override EventHandler GetEventInvoke() +{ + // Captures 'this', keeping the state alive as long as the native side + // holds a reference to the CCW for this delegate. + return (obj, e) => TargetDelegate?.Invoke(obj, e); +} +``` + +### EventSourceCache + +**File:** `InteropServices/Events/EventSourceCache.cs` + +A global, static cache that allows event registrations to survive garbage collection of `EventSource` objects. Without this, if managed code let go of an `EventSource` and then tried to resubscribe, it would lose knowledge of the existing native registration. + +**Structure:** + +``` +Global: ConcurrentDictionary (keyed by native this pointer) +Per-obj: ConcurrentDictionary> (keyed by event vtable index) +``` + +Each `EventSourceCache` instance also holds an `IWeakReference` to the native COM object, used to detect when the native object has been destroyed. If the weak reference can no longer be resolved, the cache entry is cleared. + +**When is the cache used?** + +- **On `Subscribe`**: After creating a new `EventSourceState`, `EventSourceCache.Create(...)` is called to store a weak reference to the state. This only works if the native object supports `IWeakReferenceSource`. +- **On `EventSource` construction**: The constructor calls `EventSourceCache.GetState(...)` to check if there's an existing state for this object+event. If so, it reconnects to it. +- **On `EventSourceState` disposal/finalization**: `EventSourceCache.Remove(...)` is called to clean up. + +### EventRegistrationTokenTable\ + +**File:** `InteropServices/Events/EventRegistrationTokenTable{T}.cs` + +Used exclusively for the **CCW path** (native code subscribing to managed events). This table maps `EventRegistrationToken` values to delegate instances. + +**Token structure:** + +``` +┌─────────────────────────────────┬─────────────────────────────────┐ +│ Upper 32 bits │ Lower 32 bits │ +│ typeof(T).GetHashCode() │ Incrementing counter │ +└─────────────────────────────────┴─────────────────────────────────┘ +``` + +The upper 32 bits encode the delegate type's hash, which serves as a validation check during `RemoveEventHandler` to reject tokens from a mismatched event type. The lower 32 bits are an incrementing counter (starting from a random value) that serves as the actual lookup key into an internal dictionary. + +--- + +## Instance Events (RCW → Native) + +This section describes the typical flow when managed code subscribes to/unsubscribes from an event on a native Windows Runtime object. + +### Subscribe Flow + +``` +Managed code: myObject.SomeEvent += myHandler; +``` + +This compiles down to a call through the projected interface implementation, which calls into the ABI methods layer and ultimately into `EventSource.Subscribe(handler)`. Here's what happens step by step: + +1. **Get or create the `EventSource`** — A `ConditionalWeakTable` keyed on the managed wrapper object is used to ensure there's at most one `EventSource` per event per object. + +2. **Check for existing state** — Inside `Subscribe`, under a lock, the method checks if there's already a live `EventSourceState` (via the weak reference) that still has COM references. + +3. **Create new state if needed** — If there's no live state, a new `EventSourceState` is created. Its constructor calls `GetEventInvoke()` to produce the `EventInvoke` delegate (which captures `this`). + +4. **Register in cache** — `EventSourceCache.Create(...)` is called, which stores a weak reference to the state in the global cache (only if the native object supports `IWeakReferenceSource`). + +5. **Add the handler** — `state.AddHandler(handler)` combines the new handler into `TargetDelegate` via `Delegate.Combine`. + +6. **Marshal and call native** — The `EventInvoke` delegate is marshalled to a CCW, reference tracking is initialized, and the native `add_EventName` vtable call is made. The returned `EventRegistrationToken` is stored in the state. + +### Unsubscribe Flow + +``` +Managed code: myObject.SomeEvent -= myHandler; +``` + +1. **Check for live state** — If the weak reference to the state is dead, there's nothing to do (early return). + +2. **Remove handler** — `state.RemoveHandler(handler)` calls `Delegate.Remove` on `TargetDelegate`. + +3. **If last handler removed** — When the `TargetDelegate` transitions from non-null to null, the native `remove_EventName` call is made using the stored `EventRegistrationToken`. The state is then disposed, clearing the cache entry. + +### Object Diagrams + +#### During Active Subscription + +```mermaid +graph TD + subgraph "Managed (GC Heap)" + MO["WindowsRuntimeObject
(managed wrapper / RCW)"] + CWT["ConditionalWeakTable<WRO, EventSource>"] + ES["EventSource<T>
• _nativeObjectReference
• _weakReferenceToEventSourceState"] + ESS["EventSourceState<T>
• TargetDelegate
• EventInvoke (delegate)
• Token
• _thisPtr (cache key)"] + TD["TargetDelegate
(combined handlers)"] + EI["EventInvoke delegate
(captures state)"] + WR["WeakReference<object>"] + end + + subgraph "EventSourceCache (static)" + ESC["EventSourceCache
ConcurrentDictionary<nint, ...>"] + WR2["WeakReference<object>
(to EventSourceState)"] + end + + subgraph "Native (COM)" + NO["Native WinRT Object
(event source)"] + CCW["CCW for EventInvoke
(COM ref count > 0)"] + ObjRef["WindowsRuntimeObjectReference"] + end + + CWT -->|"weak key"| MO + CWT -->|"strong value"| ES + ES -->|"strong ref"| ObjRef + ObjRef -->|"prevents release of"| NO + ES -->|"weak ref"| WR + WR -.->|"targets"| ESS + ESS -->|"strong ref"| TD + ESS -->|"strong ref"| EI + EI -->|"captures 'this'"| ESS + CCW -->|"prevents GC of"| EI + NO -->|"holds COM ref to"| CCW + ESC -->|"keyed by nint"| WR2 + WR2 -.->|"targets"| ESS +``` + +**Who keeps what alive:** + +| Object | Kept alive by | +|--------|--------------| +| `WindowsRuntimeObject` (RCW) | Application code holding a reference to the projected object | +| `EventSource` | `ConditionalWeakTable` entry (alive while the RCW is alive) | +| `EventSourceState` | The `EventInvoke` delegate captures it; the CCW of that delegate is held by the native event source | +| `EventInvoke` delegate (CCW) | The native Windows Runtime object holds a COM reference to it | +| `WindowsRuntimeObjectReference` | `EventSource._nativeObjectReference` holds a strong reference | +| `TargetDelegate` (combined handlers) | `EventSourceState.TargetDelegate` holds a strong reference | + +> **Key insight:** Even if the `EventSource` is garbage collected (e.g., the RCW is collected), the `EventSourceState` survives because the native object holds a COM reference to the CCW wrapping `EventInvoke`, which captures the state. The `EventSourceCache` can then reconnect a new `EventSource` to the surviving state. + +#### After RCW is GC'd (subscription still active on native side) + +```mermaid +graph TD + subgraph "Managed (GC Heap)" + ESS["EventSourceState<T>
• TargetDelegate
• EventInvoke
• Token"] + EI["EventInvoke delegate
(captures state)"] + end + + subgraph "EventSourceCache (static)" + ESC["EventSourceCache"] + WR["WeakReference<object>
(still resolvable)"] + end + + subgraph "Native (COM)" + NO["Native WinRT Object"] + CCW["CCW for EventInvoke
(COM ref > 0)"] + end + + NO -->|"COM ref"| CCW + CCW -->|"prevents GC of"| EI + EI -->|"captures"| ESS + ESC --> WR + WR -.->|"targets"| ESS +``` + +When someone later creates a new `EventSource` for the same object+event, it will find the surviving state through `EventSourceCache.GetState(...)` and reconnect. + +--- + +## CCW Events (Native → Managed) + +When a managed object is exposed to native code (the CCW path), and native code subscribes to a managed event, a completely different mechanism is used. + +For each interface that has events, the `Impl` class (e.g., `INotifyPropertyChangedImpl`) maintains a `ConditionalWeakTable>` keyed on the managed object instance: + +```csharp +// In INotifyPropertyChangedImpl +private static ConditionalWeakTable> PropertyChangedTable; +``` + +**add_EventName (native → managed):** + +1. Get the managed object from the CCW dispatch pointer. +2. Marshal the native delegate handler to a managed delegate. +3. Generate a token and store the handler in the token table. +4. Subscribe the managed handler to the actual .NET event. + +**remove_EventName (native → managed):** + +1. Get the managed object from the CCW dispatch pointer. +2. Look up the handler by token in the token table. +3. Remove the handler from the .NET event. + +```mermaid +graph LR + subgraph "Native" + NC["Native Caller"] + end + + subgraph "CCW Vtable" + ADD["add_PropertyChanged"] + REM["remove_PropertyChanged"] + end + + subgraph "Managed" + TT["EventRegistrationTokenTable"] + MO["Managed Object
(INotifyPropertyChanged)"] + EV["PropertyChanged event"] + end + + NC -->|"1. calls"| ADD + ADD -->|"2. generates token"| TT + ADD -->|"3. subscribes"| EV + NC -->|"passes token"| REM + REM -->|"4. looks up handler"| TT + REM -->|"5. unsubscribes"| EV +``` + +--- + +## Lifetime and GC + +### What Keeps What Alive + +The event infrastructure uses a carefully designed chain of strong and weak references to ensure: + +1. **Active subscriptions are not prematurely collected**, even if the managed `EventSource` wrapper is GC'd. +2. **No preventing GC of the managed RCW** — the `ConditionalWeakTable` uses the RCW as a weak key, so the event source doesn't keep the RCW alive. +3. **No preventing GC of native objects** — once all managed handlers are removed, the CCW reference is released and the native object can be destroyed. + +### EventInvoke Delegate and the CCW + +The `EventInvoke` delegate is the linchpin of the design: + +``` +Native event source + │ + │ holds COM reference to + ▼ + CCW (EventInvoke delegate) + │ + │ prevents GC of delegate, which captures + ▼ + EventSourceState + │ + │ holds TargetDelegate, Token, etc. +``` + +Because the lambda passed from `GetEventInvoke()` captures `this` (the `EventSourceState`), the delegate instance's `Target` property points to the state. The CCW wrapping this delegate prevents the GC from collecting both the delegate and the state, as long as the native object holds its COM reference. + +### Reference Tracking Integration + +In XAML scenarios, the native framework uses `IReferenceTracker` / `IReferenceTrackerTarget` to manage lifetimes outside of normal COM reference counting. The `EventSourceState` integrates with this: + +1. After registering the `EventInvoke` CCW with the native object, `InitializeReferenceTracking(...)` queries for `IReferenceTrackerTarget` on the CCW. +2. `HasComReferences()` checks **both** the COM reference count **and** the reference tracker count to determine if the native side still holds a reference. +3. If `HasComReferences()` returns `false`, the next `Subscribe` call knows it must create a fresh state and re-register. + +### Cleanup on GC + +When an `EventSourceState` is finalized (because the native object released its CCW reference and nothing else references the state): + +1. The finalizer calls `OnDispose()`. +2. `OnDispose()` calls `EventSourceCache.Remove(...)` to clean up the cache entry. +3. If this was the last state in the cache for that native object, the `EventSourceCache` entry itself is removed from the global dictionary. + +Explicit disposal (via `Unsubscribe`) follows the same path but also calls `GC.SuppressFinalize` to avoid double cleanup. + +--- + +## Static Events + +### How Static Events Differ from Instance Events + +For instance events, the `ConditionalWeakTable` is keyed on the managed wrapper object (`WindowsRuntimeObject`): + +```csharp +// Instance events (in the generated ABI methods class) +ConditionalWeakTable table; + +public static EventHandlerEventSource SomeEvent( + WindowsRuntimeObject thisObject, // <-- the managed RCW + WindowsRuntimeObjectReference thisReference) +{ + return table.GetOrAdd( + key: thisObject, + valueFactory: (_, ref) => new EventHandlerEventSource(ref, vtableIndex), + factoryArgument: thisReference); +} +``` + +For **static events**, there's no object instance. Instead, the generated code uses a **globally cached activation factory object reference** as the key: + +```csharp +// Generated in the projected class +private static WindowsRuntimeObjectReference __IStaticsType +{ + get + { + var ___IStaticsType = field; + if (___IStaticsType != null && ___IStaticsType.IsInCurrentContext) + { + return ___IStaticsType; + } + return field = WindowsRuntimeActivationFactory.GetActivationFactory("TypeName", iid); + } +} +``` + +And the event accessors pass this factory reference as the `thisObject`: + +```csharp +// Generated static event +public static event EventHandler SomeStaticEvent +{ + add => ABI.IStaticsTypeMethods.SomeStaticEvent(__IStaticsType, __IStaticsType).Subscribe(value); + // ^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^ + // thisObject (key) thisReference + remove => ABI.IStaticsTypeMethods.SomeStaticEvent(__IStaticsType, __IStaticsType).Unsubscribe(value); +} +``` + +In the ABI methods class, the `ConditionalWeakTable` is keyed on `object` (not `WindowsRuntimeObject`): + +```csharp +// Generated in ABI static methods class +ConditionalWeakTable _SomeStaticEvent; + +public static EventHandlerEventSource SomeStaticEvent( + object thisObject, // <-- the activation factory obj ref + WindowsRuntimeObjectReference thisReference) +{ + return _SomeStaticEvent.GetOrAdd( + key: thisObject, + valueFactory: (_, ref) => new EventHandlerEventSource(ref, vtableIndex), + factoryArgument: thisReference); +} +``` + +### The Activation Factory Cache + +The static property `__IStaticsType` uses a **semi-cached** pattern: + +1. On first access: calls `WindowsRuntimeActivationFactory.GetActivationFactory(...)` and stores the result. +2. On subsequent accesses: checks `IsInCurrentContext`. If the current COM context matches the one where the factory was created, returns the cached value. +3. If the context has changed (e.g., thread apartment transition): **re-fetches** the activation factory and **overwrites** the cached field. + +This means the cached `WindowsRuntimeObjectReference` for the activation factory can be replaced at any time if there's a context switch. + +### Context Switches and the IsInCurrentContext Check + +The `IsInCurrentContext` property (from `ContextAwareObjectReference`) compares the current COM context token to the one captured when the object reference was created: + +```csharp +private protected sealed override bool DerivedIsInCurrentContext() +{ + return _contextToken == 0 || _contextToken == WindowsRuntimeImports.CoGetContextToken(); +} +``` + +When this returns `false`, the generated code discards the cached activation factory and creates a fresh one. This is where the concern about static events arises. + +### Static Event Lifecycle Analysis + +Let's trace through a scenario to understand the lifetime implications: + +#### Scenario: Subscribe, context switch, then unsubscribe + +**Step 1: Managed code subscribes to a static event.** + +```csharp +MyRuntimeClass.StaticEvent += MyHandler; +``` + +- `__IStaticsType` is fetched (activation factory object reference `A`). +- `A` is used as the key in `ConditionalWeakTable`. +- An `EventSource` is created and cached in the table against `A`. +- `EventSource.Subscribe(handler)` creates an `EventSourceState`, registers with native, gets a token. +- `EventSourceCache.Create(A, index, state)` is called. **But**: most static/factory classes **do not implement `IWeakReferenceSource`**. In that case, `EventSourceCache.Create` returns early without caching anything. This is documented in the code: + + > _"Note that most static/factory classes do not implement `IWeakReferenceSource`, so a static codegen caching approach is also used."_ + +- The `EventSource` (and indirectly the `EventSourceState`) is kept alive through the `ConditionalWeakTable` entry keyed on `A`. + +**Step 2: A context switch occurs.** + +- Code on a different thread (different COM apartment) accesses `__IStaticsType`. +- `IsInCurrentContext` returns `false` for `A`. +- A new activation factory `B` is fetched and stored in `field`, **replacing** `A`. + +**Step 3: What happens to `A`?** + +- `A` is no longer referenced by the static `field` (it was overwritten by `B`). +- The `ConditionalWeakTable` used `A` as a **weak key**. Once `A` has no strong references, it becomes eligible for GC. +- When `A` is collected, the `ConditionalWeakTable` automatically removes the entry, which means the `EventSource` is also eligible for GC. +- The `EventSource` held a `WeakReference` to the `EventSourceState`. + +**But** — the `EventSourceState` is **still alive**, because: +- The native event source holds a COM reference to the CCW of the `EventInvoke` delegate. +- The `EventInvoke` delegate captures the `EventSourceState`. +- The state holds the `EventRegistrationToken` and the `TargetDelegate`. + +So the subscription itself is still active on the native side. The managed handler will still be called when the event fires. + +**Step 4: Managed code tries to unsubscribe.** + +```csharp +MyRuntimeClass.StaticEvent -= MyHandler; +``` + +- `__IStaticsType` now returns `B` (the new activation factory). +- `B` is used as the key in the `ConditionalWeakTable`. +- The table lookup for `B` finds **no entry** (the old entry was keyed on `A`, which is gone). +- A **new** `EventSource` is created for `B`. +- `EventSource.Unsubscribe(handler)` is called. The new event source has a `null` `_weakReferenceToEventSourceState`, since it was just created and `EventSourceCache.GetState(B, index)` returns `null` (no state was ever registered for `B`, and even if a cache had existed for the old pointer, static factories typically don't support `IWeakReferenceSource`). +- **The unsubscribe does nothing.** The early return path is hit: + + ```csharp + if (_weakReferenceToEventSourceState is null || !TryGetStateUnsafe(out EventSourceState? state)) + { + return; // <-- we end up here + } + ``` + +**Result:** The managed handler remains subscribed on the native side. The `EventSourceState` (with the token and CCW) is orphaned — still alive (held by native), but unreachable from managed code. The native event will continue to invoke the handler, and there's no way for managed code to unsubscribe. + +#### Summary of the Static Event Problem + +```mermaid +graph TD + subgraph "Before context switch" + FIELD_BEFORE["__IStaticsType (static field)"] + A["Activation Factory A"] + CWT1["ConditionalWeakTable
A → EventSource"] + ES1["EventSource<T>"] + ESS1["EventSourceState<T>
• Token
• EventInvoke"] + end + + FIELD_BEFORE -->|"strong ref"| A + CWT1 -->|"weak key"| A + CWT1 -->|"strong value"| ES1 + ES1 -->|"weak ref"| ESS1 + + subgraph "After context switch" + FIELD_AFTER["__IStaticsType (overwritten)"] + B["Activation Factory B"] + CWT2["ConditionalWeakTable
B → new EventSource (empty)"] + ES2["EventSource<T>
(fresh, no state)"] + end + + subgraph "Orphaned (still alive via native COM ref)" + ESS_ORPHAN["EventSourceState<T>
• Token (unreachable)
• EventInvoke CCW"] + end + + FIELD_AFTER -->|"strong ref"| B + CWT2 -->|"weak key"| B + CWT2 -->|"strong value"| ES2 + + NO["Native Event Source"] -->|"COM ref to CCW"| ESS_ORPHAN +``` + +This is a potential issue because: + +1. **The handler cannot be unsubscribed** — the token is trapped in the orphaned `EventSourceState`. +2. **The handler continues to fire** — the native side still invokes the CCW, which forwards to `TargetDelegate`. +3. **The `EventSourceCache` doesn't help** — static factories typically don't implement `IWeakReferenceSource`, so no cache entry was ever created. + +Note that in practice, this scenario is relatively rare: it requires a static event subscription followed by a COM apartment context switch that causes the activation factory to be re-fetched, followed by an attempt to unsubscribe. Most Windows Runtime applications run UI code on a single STA thread, and static events are uncommon. However, the theoretical possibility exists and should be understood by anyone working on or debugging the event infrastructure. + +#### Why the Root Cause Matters + +The fundamental issue is that the activation factory object reference is an **unstable key**: it can be replaced whenever a context switch occurs. Any caching strategy that is layered on top of this unstable identity — whether it's a `ConditionalWeakTable`, a static field, or anything else — will lose track of the old `EventSourceState` (and its token) when the key changes. + +For example, one might consider using a **static (codegen-level) event source field** (similar to how `WindowsRuntimeObservableVector` stores its event source in a per-instance field). But this has the same problem: the static field would be populated on first access with an `EventSource` wrapping the activation factory from that context. After a context switch, that event source would wrap a stale object reference for a different context, making native vtable calls through it invalid. Adding the same `IsInCurrentContext` invalidation pattern to the field would bring us right back to the original problem — replacing the event source loses the state. + +#### Why Alternative Cache Key Strategies Don't Help + +One might consider using a **stable, context-independent key** (e.g., a singleton per type or a type handle) instead of the activation factory object reference. This would prevent the `ConditionalWeakTable` entry from being lost on context switches. However, it introduces a different problem: the `EventSource` cached against that stable key permanently wraps the `WindowsRuntimeObjectReference` from the original context. After a context switch, `Subscribe`/`Unsubscribe` on that event source would call into the native vtable through a stale, wrong-context object reference. + +Similarly, a **static (codegen-level) event source field** (similar to how `WindowsRuntimeObservableVector` stores its event source in a per-instance field) would be populated on first access with an `EventSource` wrapping the activation factory from that context. After a context switch, that event source would wrap a stale object reference for a different context. Adding the same `IsInCurrentContext` invalidation pattern to the field would bring us right back to the original problem — replacing the event source loses the state. + +In short: **changing the key alone doesn't work** because the `EventSource` binds to a specific `WindowsRuntimeObjectReference` at construction time, and that reference is tied to a particular COM context. You'd need to also invalidate and recreate the event source when the context changes, which loses the old `EventSourceState` — exactly the problem we're trying to solve. + +#### How This Could be Resolved + +The root cause is that the activation factory object reference is an **unstable key** that can be replaced on context switches, yet it's also the key that keeps the `EventSource` (and indirectly the `EventSourceState` with its token) reachable. The fix must ensure the old activation factory (and therefore the old event source / state) remains reachable while subscriptions are active. This is safe because `EventSource` already handles cross-context calls correctly — `WindowsRuntimeObjectReference.AsValue()` / `GetThisPtrWithContextUnsafe()` will marshal to the correct context via COM context callbacks when needed. + +**Keep a strong reference to the activation factory while subscriptions are active** — The `EventSourceState` (or the `EventSource`) could hold a strong reference back to the activation factory object reference that was used during subscription. Because `ConditionalWeakTable` uses weak keys, keeping a strong reference to the old activation factory prevents it from being collected, which in turn prevents the table entry (and the `EventSource`) from being evicted. When the activation factory property is later accessed from a different context, it will produce a new `WindowsRuntimeObjectReference` and cache it in `field`, but the old one remains alive (held by the state). The `ConditionalWeakTable` lookup from the new context won't find a match (different key identity), but the **`EventSourceCache`** (keyed by native pointer, not object identity) can reconnect a new `EventSource` to the surviving `EventSourceState` — provided the native object supports `IWeakReferenceSource`. If it doesn't (which is the common case for static factories), an additional mechanism would be needed — for instance, having the generated code also maintain a direct strong reference to the `EventSource` in a static field while subscriptions are active, bypassing the `ConditionalWeakTable` for the reconnection step. + +This approach is the most targeted fix: it only keeps the factory alive for the duration of active subscriptions, the existing `EventSource` correctly handles cross-context marshalling for any native calls, and it naturally cleans up when all handlers are removed (the strong reference is released, the activation factory becomes collectible, and the `ConditionalWeakTable` entry is evicted). From e86b5cec1848e25be211087ecec8d84a677d8d2a Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Wed, 17 Jun 2026 15:07:30 -0700 Subject: [PATCH 2/8] Address PR review feedback on the events doc Deduplicate the repeated 'static event source field' paragraph in the static event analysis, and make the GC cleanup description precise about how EventSourceState.OnDispose() captures the saved native pointer before clearing the field so the EventSourceCache entry is actually removed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/event-infrastructure.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/event-infrastructure.md b/docs/event-infrastructure.md index 01a0b284d4..f1c715044f 100644 --- a/docs/event-infrastructure.md +++ b/docs/event-infrastructure.md @@ -378,8 +378,8 @@ In XAML scenarios, the native framework uses `IReferenceTracker` / `IReferenceTr When an `EventSourceState` is finalized (because the native object released its CCW reference and nothing else references the state): 1. The finalizer calls `OnDispose()`. -2. `OnDispose()` calls `EventSourceCache.Remove(...)` to clean up the cache entry. -3. If this was the last state in the cache for that native object, the `EventSourceCache` entry itself is removed from the global dictionary. +2. `OnDispose()` reads the saved native pointer (the cache key) into a local, clears the `_thisPtr` field, and — only if that pointer was non-`null` — calls `EventSourceCache.Remove(savedPointer, _index, _weakReferenceToSelf)`. Capturing the pointer *before* clearing the field is what makes the removal correct (the cache lookup uses the real key, not `null`) and idempotent (the work happens the first time `OnDispose()` runs and becomes a no-op on any later call). +3. `EventSourceCache.Remove(...)` removes the matching weak reference from the per-object cache. If this was the last state in the cache for that native object, the `EventSourceCache` entry itself is removed from the global dictionary. Explicit disposal (via `Unsubscribe`) follows the same path but also calls `GC.SuppressFinalize` to avoid double cleanup. @@ -588,8 +588,6 @@ Note that in practice, this scenario is relatively rare: it requires a static ev The fundamental issue is that the activation factory object reference is an **unstable key**: it can be replaced whenever a context switch occurs. Any caching strategy that is layered on top of this unstable identity — whether it's a `ConditionalWeakTable`, a static field, or anything else — will lose track of the old `EventSourceState` (and its token) when the key changes. -For example, one might consider using a **static (codegen-level) event source field** (similar to how `WindowsRuntimeObservableVector` stores its event source in a per-instance field). But this has the same problem: the static field would be populated on first access with an `EventSource` wrapping the activation factory from that context. After a context switch, that event source would wrap a stale object reference for a different context, making native vtable calls through it invalid. Adding the same `IsInCurrentContext` invalidation pattern to the field would bring us right back to the original problem — replacing the event source loses the state. - #### Why Alternative Cache Key Strategies Don't Help One might consider using a **stable, context-independent key** (e.g., a singleton per type or a type handle) instead of the activation factory object reference. This would prevent the `ConditionalWeakTable` entry from being lost on context switches. However, it introduces a different problem: the `EventSource` cached against that stable key permanently wraps the `WindowsRuntimeObjectReference` from the original context. After a context switch, `Subscribe`/`Unsubscribe` on that event source would call into the native vtable through a stale, wrong-context object reference. From ff888ea30484a8bb9b493534a18e955f3c3e0e39 Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Wed, 17 Jun 2026 15:10:11 -0700 Subject: [PATCH 3/8] Correct instance-event event source storage in the events doc Instance events store their EventSource in a per-instance field on the projected runtime class (the common path), not in a ConditionalWeakTable as the doc implied. The ConditionalWeakTable keyed on the runtime class instance is only a fallback for certain interfaces (and the mechanism used for static events). Update the subscribe flow, object diagram, lifetime tables, and the instance-vs-static contrast to reflect this. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/event-infrastructure.md | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/docs/event-infrastructure.md b/docs/event-infrastructure.md index f1c715044f..e9d35f22be 100644 --- a/docs/event-infrastructure.md +++ b/docs/event-infrastructure.md @@ -174,7 +174,7 @@ Managed code: myObject.SomeEvent += myHandler; This compiles down to a call through the projected interface implementation, which calls into the ABI methods layer and ultimately into `EventSource.Subscribe(handler)`. Here's what happens step by step: -1. **Get or create the `EventSource`** — A `ConditionalWeakTable` keyed on the managed wrapper object is used to ensure there's at most one `EventSource` per event per object. +1. **Get or create the `EventSource`** — The projected runtime class holds one `EventSource` per event, created lazily on first use and stored in a per-instance field (`_eventSource_`). This guarantees at most one `EventSource` per event per object, with a lifetime bounded by the runtime class instance. (For a small subset of interfaces, the event source is instead cached in a `ConditionalWeakTable` keyed on the runtime class instance, in the generated ABI methods class — but the lifetime semantics are the same.) 2. **Check for existing state** — Inside `Subscribe`, under a lock, the method checks if there's already a live `EventSourceState` (via the weak reference) that still has COM references. @@ -205,8 +205,7 @@ Managed code: myObject.SomeEvent -= myHandler; ```mermaid graph TD subgraph "Managed (GC Heap)" - MO["WindowsRuntimeObject
(managed wrapper / RCW)"] - CWT["ConditionalWeakTable<WRO, EventSource>"] + MO["WindowsRuntimeObject
(managed wrapper / RCW)
• _eventSource_<Name> field"] ES["EventSource<T>
• _nativeObjectReference
• _weakReferenceToEventSourceState"] ESS["EventSourceState<T>
• TargetDelegate
• EventInvoke (delegate)
• Token
• _thisPtr (cache key)"] TD["TargetDelegate
(combined handlers)"] @@ -225,8 +224,7 @@ graph TD ObjRef["WindowsRuntimeObjectReference"] end - CWT -->|"weak key"| MO - CWT -->|"strong value"| ES + MO -->|"per-instance field (strong)"| ES ES -->|"strong ref"| ObjRef ObjRef -->|"prevents release of"| NO ES -->|"weak ref"| WR @@ -245,7 +243,7 @@ graph TD | Object | Kept alive by | |--------|--------------| | `WindowsRuntimeObject` (RCW) | Application code holding a reference to the projected object | -| `EventSource` | `ConditionalWeakTable` entry (alive while the RCW is alive) | +| `EventSource` | The runtime class instance's per-instance field (or a `ConditionalWeakTable` entry keyed weakly on it); alive while the RCW is alive | | `EventSourceState` | The `EventInvoke` delegate captures it; the CCW of that delegate is held by the native event source | | `EventInvoke` delegate (CCW) | The native Windows Runtime object holds a COM reference to it | | `WindowsRuntimeObjectReference` | `EventSource._nativeObjectReference` holds a strong reference | @@ -342,7 +340,7 @@ graph LR The event infrastructure uses a carefully designed chain of strong and weak references to ensure: 1. **Active subscriptions are not prematurely collected**, even if the managed `EventSource` wrapper is GC'd. -2. **No preventing GC of the managed RCW** — the `ConditionalWeakTable` uses the RCW as a weak key, so the event source doesn't keep the RCW alive. +2. **No preventing GC of the managed RCW** — the event source lives in a field on the runtime class instance (or a `ConditionalWeakTable` keyed weakly on it), so it shares the RCW's lifetime and never keeps the RCW alive on its own. 3. **No preventing GC of native objects** — once all managed handlers are removed, the CCW reference is released and the native object can be destroyed. ### EventInvoke Delegate and the CCW @@ -389,23 +387,23 @@ Explicit disposal (via `Unsubscribe`) follows the same path but also calls `GC.S ### How Static Events Differ from Instance Events -For instance events, the `ConditionalWeakTable` is keyed on the managed wrapper object (`WindowsRuntimeObject`): +For instance events, the `EventSource` is bound to the lifetime of the **runtime class instance**. The projected class stores it in a per-instance field, created lazily on first use: ```csharp -// Instance events (in the generated ABI methods class) -ConditionalWeakTable table; +// Instance events: the event source lives in a per-instance field on the +// projected runtime class, created lazily on first access. +private EventHandlerEventSource _eventSource_SomeEvent => + field ??= new EventHandlerEventSource(NativeObjectReference, vtableIndex); -public static EventHandlerEventSource SomeEvent( - WindowsRuntimeObject thisObject, // <-- the managed RCW - WindowsRuntimeObjectReference thisReference) +public event EventHandler SomeEvent { - return table.GetOrAdd( - key: thisObject, - valueFactory: (_, ref) => new EventHandlerEventSource(ref, vtableIndex), - factoryArgument: thisReference); + add => _eventSource_SomeEvent.Subscribe(value); + remove => _eventSource_SomeEvent.Unsubscribe(value); } ``` +(The real generated getter initializes the field atomically with `Interlocked.CompareExchange`. A subset of interfaces — fast-ABI exclusive, non-default — instead route through a `ConditionalWeakTable` keyed on the runtime class instance, in the generated ABI methods class. Either way the key is a stable identity — the `WindowsRuntimeObject` itself — that lives exactly as long as the object.) + For **static events**, there's no object instance. Instead, the generated code uses a **globally cached activation factory object reference** as the key: ```csharp From 1708f1b14051f4d88f7c1db0d74f8a1e2384b95f Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Wed, 17 Jun 2026 15:10:34 -0700 Subject: [PATCH 4/8] Fix activation factory API reference in the events doc The lazy static factory object reference is produced by WindowsRuntimeObjectReference.GetActivationFactory(...), not WindowsRuntimeActivationFactory.GetActivationFactory(...) (that type only exposes GetActivationFactoryUnsafe, which returns a raw pointer). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/event-infrastructure.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/event-infrastructure.md b/docs/event-infrastructure.md index e9d35f22be..652c59ef37 100644 --- a/docs/event-infrastructure.md +++ b/docs/event-infrastructure.md @@ -417,7 +417,7 @@ private static WindowsRuntimeObjectReference __IStaticsType { return ___IStaticsType; } - return field = WindowsRuntimeActivationFactory.GetActivationFactory("TypeName", iid); + return field = WindowsRuntimeObjectReference.GetActivationFactory("TypeName", iid); } } ``` @@ -456,7 +456,7 @@ public static EventHandlerEventSource SomeStaticEvent( The static property `__IStaticsType` uses a **semi-cached** pattern: -1. On first access: calls `WindowsRuntimeActivationFactory.GetActivationFactory(...)` and stores the result. +1. On first access: calls `WindowsRuntimeObjectReference.GetActivationFactory(...)` and stores the result. 2. On subsequent accesses: checks `IsInCurrentContext`. If the current COM context matches the one where the factory was created, returns the cached value. 3. If the context has changed (e.g., thread apartment transition): **re-fetches** the activation factory and **overwrites** the cached field. From 1124dbfd446f7cca90529db4d7952ec3ec7a6554 Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Wed, 17 Jun 2026 15:12:35 -0700 Subject: [PATCH 5/8] Correct the cross-context analysis of the static event problem The 'alternative cache key strategies' analysis claimed a stable key or static event source field would make native add/remove calls go through a stale, wrong-context object reference. That is inaccurate: context-aware object references marshal cross-context calls correctly (GetThisPtrUnsafe routes through GetThisPtrWithContextUnsafe), as the proposed-fix section already notes. Reframe the trade-off around the real cost (lifetime: a stable key or static field would pin the event source alive forever, unlike a per-instance field bounded by the runtime class instance). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/event-infrastructure.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/event-infrastructure.md b/docs/event-infrastructure.md index 652c59ef37..36d1062dbb 100644 --- a/docs/event-infrastructure.md +++ b/docs/event-infrastructure.md @@ -586,13 +586,13 @@ Note that in practice, this scenario is relatively rare: it requires a static ev The fundamental issue is that the activation factory object reference is an **unstable key**: it can be replaced whenever a context switch occurs. Any caching strategy that is layered on top of this unstable identity — whether it's a `ConditionalWeakTable`, a static field, or anything else — will lose track of the old `EventSourceState` (and its token) when the key changes. -#### Why Alternative Cache Key Strategies Don't Help +#### Trade-offs of Alternative Cache Key Strategies -One might consider using a **stable, context-independent key** (e.g., a singleton per type or a type handle) instead of the activation factory object reference. This would prevent the `ConditionalWeakTable` entry from being lost on context switches. However, it introduces a different problem: the `EventSource` cached against that stable key permanently wraps the `WindowsRuntimeObjectReference` from the original context. After a context switch, `Subscribe`/`Unsubscribe` on that event source would call into the native vtable through a stale, wrong-context object reference. +One might consider using a **stable, context-independent key** (e.g., a singleton per type or a type handle) instead of the activation factory object reference. This *does* fix the unsubscribe-after-context-switch problem: the same `EventSource` is found on every access, so it always reaches the original `EventSourceState` (and its token). Importantly, it stays correct across contexts — the `EventSource` wraps a context-aware `WindowsRuntimeObjectReference` (the only case in which this problem can arise at all), and its native `add`/`remove` calls are automatically marshalled to the right context: `GetThisPtrUnsafe()` routes through `GetThisPtrWithContextUnsafe()`, which resolves the pointer for the *current* context via an agile reference. The cached object reference originating from the "original" context is therefore **not** a correctness problem. -Similarly, a **static (codegen-level) event source field** (similar to how `WindowsRuntimeObservableVector` stores its event source in a per-instance field) would be populated on first access with an `EventSource` wrapping the activation factory from that context. After a context switch, that event source would wrap a stale object reference for a different context. Adding the same `IsInCurrentContext` invalidation pattern to the field would bring us right back to the original problem — replacing the event source loses the state. +The real cost is **lifetime**. A stable key — or a `static` codegen-level event source field, analogous to how `WindowsRuntimeObservableVector` stores its event source in a *per-instance* field — never dies, so the `EventSource` it holds (and the first-context `WindowsRuntimeObjectReference` that event source wraps) would stay alive for the lifetime of the process, even after every handler has been unsubscribed. For an *instance* event this is a non-issue: the per-instance field is collected together with the runtime class instance, so it is naturally bounded. A *static* event has no such bound, so a naive static field or stable key turns a transient subscription into a permanent leak. -In short: **changing the key alone doesn't work** because the `EventSource` binds to a specific `WindowsRuntimeObjectReference` at construction time, and that reference is tied to a particular COM context. You'd need to also invalidate and recreate the event source when the context changes, which loses the old `EventSourceState` — exactly the problem we're trying to solve. +In short, changing the key alone is not the whole answer: it must be paired with lifetime management that keeps the event source reachable **only while subscriptions are active** and releases it once the last handler is removed — which is exactly what the fix below does. #### How This Could be Resolved From 7f6cb820bc2ba147d9907b8f61d69660e4e9ee29 Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Wed, 17 Jun 2026 15:14:48 -0700 Subject: [PATCH 6/8] Add thread-safety notes and observable-collection tie-in to the events doc Document the locking model (EventSource instance lock, EventSourceCache ReaderWriterLockSlim, EventRegistrationTokenTable dictionary lock) and note that the projected observable collection types reuse the same event source Subscribe/Unsubscribe path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/event-infrastructure.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/event-infrastructure.md b/docs/event-infrastructure.md index 36d1062dbb..421fa13111 100644 --- a/docs/event-infrastructure.md +++ b/docs/event-infrastructure.md @@ -21,6 +21,7 @@ This document provides a deep dive into how CsWinRT's event infrastructure works - [EventInvoke Delegate and the CCW](#eventinvoke-delegate-and-the-ccw) - [Reference Tracking Integration](#reference-tracking-integration) - [Cleanup on GC](#cleanup-on-gc) + - [Thread Safety](#thread-safety) - [Static Events](#static-events) - [How Static Events Differ from Instance Events](#how-static-events-differ-from-instance-events) - [The Activation Factory Cache](#the-activation-factory-cache) @@ -89,6 +90,8 @@ Each derived type implements two abstract methods: - `ConvertToUnmanaged(T handler)` — marshals the delegate to a native CCW. - `CreateEventSourceState()` — creates the concrete `EventSourceState` subclass. +The collection-change event sources (`VectorChangedEventHandlerEventSource`, `MapChangedEventHandlerEventSource`) back the `VectorChanged` / `MapChanged` events on the projected observable collection types (e.g. `WindowsRuntimeObservableVector`), which route through the same `Subscribe` / `Unsubscribe` path as any other instance event. + ### EventSourceState\ **File:** `InteropServices/Events/EventSourceState{T}.cs` @@ -381,6 +384,14 @@ When an `EventSourceState` is finalized (because the native object released i Explicit disposal (via `Unsubscribe`) follows the same path but also calls `GC.SuppressFinalize` to avoid double cleanup. +### Thread Safety + +All of the shared infrastructure is safe to use concurrently: + +- **`EventSource`** serializes `Subscribe` / `Unsubscribe` for a given event with a `lock` on the event source instance, so concurrent `+=` / `-=` on the same event never race on the shared `EventSourceState`. +- **`EventSourceCache`** coordinates its global map with a `ReaderWriterLockSlim` (held as a read lock while adding or updating entries, and as a write lock only when removing the last entry for a native object) layered on top of `ConcurrentDictionary`, and takes a per-cache lock when resolving its `IWeakReference` target. +- **`EventRegistrationTokenTable`** (the CCW path) serializes token allocation and removal with a `lock` on its backing dictionary, so native callers adding and removing handlers concurrently always receive distinct tokens. + --- ## Static Events From 1178a44f0a5add3d107df842b26877071c2f4604 Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Wed, 17 Jun 2026 15:18:05 -0700 Subject: [PATCH 7/8] Refine event doc wording from review pass Tighten the instance-event lifetime phrasing (the per-instance field has no key) and note that context-aware cross-context pointer resolution is best-effort (it falls back to the original pointer if the agile/context resolution fails, e.g. the original apartment was torn down). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/event-infrastructure.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/event-infrastructure.md b/docs/event-infrastructure.md index 421fa13111..f522cc91ed 100644 --- a/docs/event-infrastructure.md +++ b/docs/event-infrastructure.md @@ -413,7 +413,7 @@ public event EventHandler SomeEvent } ``` -(The real generated getter initializes the field atomically with `Interlocked.CompareExchange`. A subset of interfaces — fast-ABI exclusive, non-default — instead route through a `ConditionalWeakTable` keyed on the runtime class instance, in the generated ABI methods class. Either way the key is a stable identity — the `WindowsRuntimeObject` itself — that lives exactly as long as the object.) +(The real generated getter initializes the field atomically with `Interlocked.CompareExchange`. A subset of interfaces — fast-ABI exclusive, non-default — instead route through a `ConditionalWeakTable` keyed on the runtime class instance, in the generated ABI methods class. Either way the event source's lifetime is bounded by the runtime class instance — the `WindowsRuntimeObject` itself — a stable identity that lives exactly as long as the object.) For **static events**, there's no object instance. Instead, the generated code uses a **globally cached activation factory object reference** as the key: @@ -599,7 +599,7 @@ The fundamental issue is that the activation factory object reference is an **un #### Trade-offs of Alternative Cache Key Strategies -One might consider using a **stable, context-independent key** (e.g., a singleton per type or a type handle) instead of the activation factory object reference. This *does* fix the unsubscribe-after-context-switch problem: the same `EventSource` is found on every access, so it always reaches the original `EventSourceState` (and its token). Importantly, it stays correct across contexts — the `EventSource` wraps a context-aware `WindowsRuntimeObjectReference` (the only case in which this problem can arise at all), and its native `add`/`remove` calls are automatically marshalled to the right context: `GetThisPtrUnsafe()` routes through `GetThisPtrWithContextUnsafe()`, which resolves the pointer for the *current* context via an agile reference. The cached object reference originating from the "original" context is therefore **not** a correctness problem. +One might consider using a **stable, context-independent key** (e.g., a singleton per type or a type handle) instead of the activation factory object reference. This *does* fix the unsubscribe-after-context-switch problem: the same `EventSource` is found on every access, so it always reaches the original `EventSourceState` (and its token). Importantly, it stays correct across contexts — the `EventSource` wraps a context-aware `WindowsRuntimeObjectReference` (the only case in which this problem can arise at all), and its native `add`/`remove` calls are marshalled to the right context: `GetThisPtrUnsafe()` routes through `GetThisPtrWithContextUnsafe()`, which resolves the pointer for the *current* context via an agile reference (falling back to the original pointer only on a best-effort basis if that agile/context resolution fails — for example, if the original apartment has already been torn down). The cached object reference originating from the "original" context is therefore **not**, in itself, a correctness problem. The real cost is **lifetime**. A stable key — or a `static` codegen-level event source field, analogous to how `WindowsRuntimeObservableVector` stores its event source in a *per-instance* field — never dies, so the `EventSource` it holds (and the first-context `WindowsRuntimeObjectReference` that event source wraps) would stay alive for the lifetime of the process, even after every handler has been unsubscribed. For an *instance* event this is a non-issue: the per-instance field is collected together with the runtime class instance, so it is naturally bounded. A *static* event has no such bound, so a naive static field or stable key turns a transient subscription into a permanent leak. From cd4ce1fa0948c2dfedf3eba88f9c491597fa0807 Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Wed, 17 Jun 2026 15:53:26 -0700 Subject: [PATCH 8/8] Document the dynamic-interface-cast event source path in the events doc The ConditionalWeakTable keyed on the WindowsRuntimeObject is not just a fast-ABI fallback: it is the mechanism used whenever an event-bearing interface is reached through a dynamic interface cast (a [DynamicInterfaceCastableImplementation] proxy), since the proxy interface has no per-instance fields. This covers the mapped interfaces (INotifyPropertyChanged, INotifyDataErrorInfo, ...), the WindowsRuntimeInspectable fallback, and generated IDIC proxies. Reframe the instance-event storage as: per-instance field for a concrete projected runtime class, vs a ConditionalWeakTable keyed on the object for a dynamic interface cast. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/event-infrastructure.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/event-infrastructure.md b/docs/event-infrastructure.md index f522cc91ed..8a95a8813e 100644 --- a/docs/event-infrastructure.md +++ b/docs/event-infrastructure.md @@ -177,7 +177,11 @@ Managed code: myObject.SomeEvent += myHandler; This compiles down to a call through the projected interface implementation, which calls into the ABI methods layer and ultimately into `EventSource.Subscribe(handler)`. Here's what happens step by step: -1. **Get or create the `EventSource`** — The projected runtime class holds one `EventSource` per event, created lazily on first use and stored in a per-instance field (`_eventSource_`). This guarantees at most one `EventSource` per event per object, with a lifetime bounded by the runtime class instance. (For a small subset of interfaces, the event source is instead cached in a `ConditionalWeakTable` keyed on the runtime class instance, in the generated ABI methods class — but the lifetime semantics are the same.) +1. **Get or create the `EventSource`** — There is at most one `EventSource` per event per managed object, and its lifetime is bounded by that object (a `WindowsRuntimeObject`). It is stored in one of two ways, depending on how the object implements the interface: + - **Concrete projected runtime class** (the event is declared on the class): in a per-instance field (`_eventSource_`) on the class, created lazily. + - **Dynamic interface cast** (the interface is provided by a `[DynamicInterfaceCastableImplementation]` proxy — the case for the mapped interfaces such as `INotifyPropertyChanged`, and for any object that reaches the interface via a cast rather than a declared field, e.g. the `WindowsRuntimeInspectable` fallback): in a `ConditionalWeakTable>` keyed on the object instance, held by the generated ABI `*Methods` class. The proxy interface can't carry per-instance fields, so the table is what ties the event source to the object's lifetime. + + Either way the `EventSource` shares the lifetime of the `WindowsRuntimeObject`. 2. **Check for existing state** — Inside `Subscribe`, under a lock, the method checks if there's already a live `EventSourceState` (via the weak reference) that still has COM references. @@ -241,12 +245,14 @@ graph TD WR2 -.->|"targets"| ESS ``` +> This diagram shows a **concrete projected runtime class**, where the `EventSource` lives in a per-instance field. When the event is reached through a **dynamic interface cast** instead, that field is replaced by a `ConditionalWeakTable>` entry keyed on the object (held by the ABI `*Methods` class) — the storage differs, but the lifetime (bounded by the `WindowsRuntimeObject`) is the same. + **Who keeps what alive:** | Object | Kept alive by | |--------|--------------| | `WindowsRuntimeObject` (RCW) | Application code holding a reference to the projected object | -| `EventSource` | The runtime class instance's per-instance field (or a `ConditionalWeakTable` entry keyed weakly on it); alive while the RCW is alive | +| `EventSource` | The `WindowsRuntimeObject` that exposes the event — via a per-instance field (projected runtime class) or a `ConditionalWeakTable` entry keyed weakly on the object (dynamic interface cast); alive while the RCW is alive | | `EventSourceState` | The `EventInvoke` delegate captures it; the CCW of that delegate is held by the native event source | | `EventInvoke` delegate (CCW) | The native Windows Runtime object holds a COM reference to it | | `WindowsRuntimeObjectReference` | `EventSource._nativeObjectReference` holds a strong reference | @@ -398,7 +404,7 @@ All of the shared infrastructure is safe to use concurrently: ### How Static Events Differ from Instance Events -For instance events, the `EventSource` is bound to the lifetime of the **runtime class instance**. The projected class stores it in a per-instance field, created lazily on first use: +For instance events, the `EventSource` is bound to the lifetime of the **managed object** that exposes the event. A concrete projected runtime class stores it in a per-instance field, created lazily on first use: ```csharp // Instance events: the event source lives in a per-instance field on the @@ -413,7 +419,7 @@ public event EventHandler SomeEvent } ``` -(The real generated getter initializes the field atomically with `Interlocked.CompareExchange`. A subset of interfaces — fast-ABI exclusive, non-default — instead route through a `ConditionalWeakTable` keyed on the runtime class instance, in the generated ABI methods class. Either way the event source's lifetime is bounded by the runtime class instance — the `WindowsRuntimeObject` itself — a stable identity that lives exactly as long as the object.) +(The real generated getter initializes the field atomically with `Interlocked.CompareExchange`. This per-instance field is used when the event is declared on a concrete projected runtime class. When the interface is instead reached through a **dynamic interface cast** — a `[DynamicInterfaceCastableImplementation]` proxy, as used for the mapped interfaces (`INotifyPropertyChanged`, `INotifyDataErrorInfo`, …) and for any non-projected object such as the `WindowsRuntimeInspectable` fallback — the proxy has no instance fields, so the event source is cached in a `ConditionalWeakTable>` keyed on the object instance, in the ABI `*Methods` class. Either way the event source's lifetime is bounded by the `WindowsRuntimeObject` itself — a stable identity that lives exactly as long as the object.) For **static events**, there's no object instance. Instead, the generated code uses a **globally cached activation factory object reference** as the key: @@ -601,7 +607,7 @@ The fundamental issue is that the activation factory object reference is an **un One might consider using a **stable, context-independent key** (e.g., a singleton per type or a type handle) instead of the activation factory object reference. This *does* fix the unsubscribe-after-context-switch problem: the same `EventSource` is found on every access, so it always reaches the original `EventSourceState` (and its token). Importantly, it stays correct across contexts — the `EventSource` wraps a context-aware `WindowsRuntimeObjectReference` (the only case in which this problem can arise at all), and its native `add`/`remove` calls are marshalled to the right context: `GetThisPtrUnsafe()` routes through `GetThisPtrWithContextUnsafe()`, which resolves the pointer for the *current* context via an agile reference (falling back to the original pointer only on a best-effort basis if that agile/context resolution fails — for example, if the original apartment has already been torn down). The cached object reference originating from the "original" context is therefore **not**, in itself, a correctness problem. -The real cost is **lifetime**. A stable key — or a `static` codegen-level event source field, analogous to how `WindowsRuntimeObservableVector` stores its event source in a *per-instance* field — never dies, so the `EventSource` it holds (and the first-context `WindowsRuntimeObjectReference` that event source wraps) would stay alive for the lifetime of the process, even after every handler has been unsubscribed. For an *instance* event this is a non-issue: the per-instance field is collected together with the runtime class instance, so it is naturally bounded. A *static* event has no such bound, so a naive static field or stable key turns a transient subscription into a permanent leak. +The real cost is **lifetime**. A stable key — or a `static` codegen-level event source field, analogous to how `WindowsRuntimeObservableVector` stores its event source in a *per-instance* field — never dies, so the `EventSource` it holds (and the first-context `WindowsRuntimeObjectReference` that event source wraps) would stay alive for the lifetime of the process, even after every handler has been unsubscribed. For an *instance* event this is a non-issue: whatever holds the event source (a per-instance field, or a `ConditionalWeakTable` entry keyed on the object) is collected together with the runtime class instance, so it is naturally bounded. A *static* event has no such bound, so a naive static field or stable key turns a transient subscription into a permanent leak. In short, changing the key alone is not the whole answer: it must be paired with lifetime management that keeps the event source reachable **only while subscriptions are active** and releases it once the last handler is removed — which is exactly what the fix below does.