Skip to content

Commit 00a4853

Browse files
authored
fix event-invocation on AOT targets (#3158)
investigate and fix #3157
1 parent 09d8065 commit 00a4853

4 files changed

Lines changed: 364 additions & 18 deletions

File tree

‎src/StackExchange.Redis/Delegates.cs‎

Lines changed: 82 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
#if NET8_0_OR_GREATER
1+
#if NET9_0_OR_GREATER
2+
// .NET 9 added Delegate.EnumerateInvocationList, which is exactly the allocation-free
3+
// enumerator we want, is runtime-agnostic, and works on NativeAOT; prefer it
4+
#define BCL_INVOCATION_LIST
5+
#elif NET8_0_OR_GREATER
26
#define UNSAFE_ACCESSOR // retain ability to disable easily
37
#endif
48

@@ -7,7 +11,7 @@
711
using System.Collections.Generic;
812
using System.Runtime.CompilerServices;
913

10-
#if !UNSAFE_ACCESSOR
14+
#if !UNSAFE_ACCESSOR && !BCL_INVOCATION_LIST
1115
using System.Reflection;
1216
using System.Reflection.Emit;
1317
#endif
@@ -25,7 +29,7 @@ internal static class Delegates
2529
/// <typeparam name="T">The type of delegate being enumerated.</typeparam>
2630
[MethodImpl(MethodImplOptions.AggressiveInlining)]
2731
public static DelegateEnumerator<T> GetEnumerator<T>(this T? handler) where T : MulticastDelegate
28-
=> handler is null ? default : new(handler);
32+
=> new(handler);
2933

3034
/// <summary>
3135
/// Iterate over the individual elements of a multicast delegate (without allocation).
@@ -41,8 +45,15 @@ public static DelegateEnumerable<T> AsEnumerable<T>(this T? handler) where T : M
4145
[MethodImpl(MethodImplOptions.AggressiveInlining)]
4246
public static bool IsSingle(this MulticastDelegate handler)
4347
{
44-
#if UNSAFE_ACCESSOR
45-
return s_getArr(handler) is null;
48+
#if BCL_INVOCATION_LIST
49+
var iterator = Delegate.EnumerateInvocationList(handler);
50+
return iterator.MoveNext() && !iterator.MoveNext();
51+
#elif UNSAFE_ACCESSOR
52+
if (s_isAvailable)
53+
{
54+
return s_getArr(handler) is null;
55+
}
56+
return handler.GetInvocationList().Length == 1;
4657
#else
4758
if (s_isAvailable)
4859
{
@@ -63,7 +74,11 @@ public static bool IsSingle(this MulticastDelegate handler)
6374
/// </summary>
6475
public static bool IsSupported => s_isAvailable;
6576

66-
#if UNSAFE_ACCESSOR
77+
#if BCL_INVOCATION_LIST
78+
#pragma warning disable SA1303
79+
private const bool s_isAvailable = true;
80+
#pragma warning restore SA1303
81+
#elif UNSAFE_ACCESSOR
6782
#pragma warning disable SA1300
6883
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_invocationList")]
6984
private static extern ref readonly object? s_getArr(MulticastDelegate handler);
@@ -73,10 +88,34 @@ public static bool IsSingle(this MulticastDelegate handler)
7388

7489
// ReSharper disable once InconsistentNaming
7590
#pragma warning disable SA1303
76-
private const bool s_isAvailable = true;
91+
private static readonly bool s_isAvailable = IsAvailable();
7792
#pragma warning restore SA1303
7893

7994
#pragma warning restore SA1300
95+
96+
private static bool IsAvailable()
97+
{
98+
// these fields are an implementation detail of the CoreCLR/.NET Framework delegate layout;
99+
// other runtimes differ (NativeAOT keeps the invocation list on Delegate, in a different
100+
// shape), in which case the accessors above throw MissingFieldException on first use - so:
101+
// validate before we trust them, and fall back to GetInvocationList() when unsure (see #3157)
102+
try
103+
{
104+
Action probe = Probe;
105+
if (s_getArr(probe) is not null) return false; // expect: single-target => no list
106+
107+
probe += Probe; // (combine does not de-duplicate, so this is genuinely two targets)
108+
return s_getArr(probe) is object[] arr && arr.Length >= 2 // (the array can have spare capacity)
109+
&& (int)s_getCount(probe) == 2
110+
&& arr[0] is Action && arr[1] is Action;
111+
}
112+
catch
113+
{
114+
return false;
115+
}
116+
117+
static void Probe() { }
118+
}
80119
#else
81120
#pragma warning disable SA1300
82121
private static readonly Func<MulticastDelegate, object?>? s_getArr;
@@ -153,8 +192,7 @@ private static Func<MulticastDelegate, T> GetViaReflection<T>(FieldInfo field)
153192
/// Iterate over the individual elements of a multicast delegate (without allocation).
154193
/// </summary>
155194
[MethodImpl(MethodImplOptions.AggressiveInlining)]
156-
public DelegateEnumerator<T> GetEnumerator()
157-
=> _handler is null ? default : new DelegateEnumerator<T>(_handler);
195+
public DelegateEnumerator<T> GetEnumerator() => new(_handler);
158196
IEnumerator<T> IEnumerable<T>.GetEnumerator() => GetEnumerator();
159197
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
160198
}
@@ -165,21 +203,33 @@ public DelegateEnumerator<T> GetEnumerator()
165203
/// <typeparam name="T">The type of delegate being enumerated.</typeparam>
166204
public struct DelegateEnumerator<T> : IEnumerator<T> where T : MulticastDelegate
167205
{
168-
private readonly T _handler;
206+
private readonly T? _handler;
207+
#if BCL_INVOCATION_LIST
208+
private Delegate.InvocationListEnumerator<T> _iterator;
209+
#else
169210
private readonly object[]? _arr;
170211
private readonly int _count;
171212
private int _index;
172213
private T? _current;
173-
internal DelegateEnumerator(T handler)
214+
#endif
215+
216+
internal DelegateEnumerator(T? handler)
174217
{
175-
// Debug.Assert(handler is not null);
176218
_handler = handler;
177-
#if UNSAFE_ACCESSOR
178-
_arr = s_getArr(handler) as object[];
179-
_count = _arr is null ? 1 : (int)s_getCount(handler);
219+
#if BCL_INVOCATION_LIST
220+
_iterator = Delegate.EnumerateInvocationList(handler);
180221
#else
181-
if (s_isAvailable)
222+
if (handler is null)
223+
{
224+
_arr = null;
225+
_count = 0;
226+
}
227+
else if (s_isAvailable)
182228
{
229+
#if UNSAFE_ACCESSOR
230+
_arr = s_getArr(handler) as object[];
231+
_count = _arr is null ? 1 : (int)s_getCount(handler);
232+
#else
183233
if (s_delegates is null)
184234
{
185235
_arr = s_getArr!(handler) as object[];
@@ -191,16 +241,18 @@ internal DelegateEnumerator(T handler)
191241
_arr = s_delegates(handler);
192242
_count = _arr?.Length ?? 1;
193243
}
244+
#endif
194245
}
195246
else
196247
{
197248
// ReSharper disable once CoVariantArrayConversion
198249
_arr = handler.GetInvocationList();
199250
_count = _arr.Length;
200251
}
201-
#endif
252+
202253
_current = null;
203254
_index = -1;
255+
#endif
204256
}
205257

206258
/// <summary>
@@ -209,7 +261,11 @@ internal DelegateEnumerator(T handler)
209261
public T Current
210262
{
211263
[MethodImpl(MethodImplOptions.AggressiveInlining)]
264+
#if BCL_INVOCATION_LIST
265+
get => _iterator.Current;
266+
#else
212267
get => _current!;
268+
#endif
213269
}
214270

215271
object? IEnumerator.Current => Current;
@@ -222,24 +278,32 @@ void IDisposable.Dispose() { }
222278
[MethodImpl(MethodImplOptions.AggressiveInlining)]
223279
public bool MoveNext()
224280
{
281+
#if BCL_INVOCATION_LIST
282+
return _iterator.MoveNext();
283+
#else
225284
var next = _index + 1;
226285
if (next >= _count)
227286
{
228287
_current = null;
229288
return false;
230289
}
231-
_current = _arr is null ? _handler : (T)_arr[next];
290+
_current = _arr is null ? _handler! : (T)_arr[next];
232291
_index = next;
233292
return true;
293+
#endif
234294
}
235295

236296
/// <summary>
237297
/// Reset the enumerator, allowing the sequence to be repeated.
238298
/// </summary>
239299
public void Reset()
240300
{
301+
#if BCL_INVOCATION_LIST
302+
_iterator = Delegate.EnumerateInvocationList(_handler);
303+
#else
241304
_current = null;
242305
_index = -1;
306+
#endif
243307
}
244308
}
245309
}

‎tests/StackExchange.Redis.Tests/DelegateTests.cs‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,69 @@ public void Foo(int count)
5151
Assert.Equal(i, captured[i]);
5252
}
5353
}
54+
55+
[Theory]
56+
[InlineData(1)]
57+
[InlineData(2)]
58+
[InlineData(25)]
59+
public void MatchesGetInvocationList(int count)
60+
{
61+
Action? action = null;
62+
for (int i = 0; i < count; i++)
63+
{
64+
action += Noop;
65+
}
66+
67+
Assert.NotNull(action);
68+
var expected = action.GetInvocationList();
69+
Assert.Equal(count, expected.Length);
70+
Assert.Equal(count == 1, action.IsSingle());
71+
72+
int index = 0;
73+
foreach (var inner in action.AsEnumerable())
74+
{
75+
Assert.Same(expected[index++], inner);
76+
}
77+
Assert.Equal(count, index);
78+
79+
// and again, to check that removal keeps things consistent
80+
action -= Noop;
81+
if (count == 1)
82+
{
83+
Assert.Null(action);
84+
return;
85+
}
86+
87+
Assert.NotNull(action);
88+
expected = action.GetInvocationList();
89+
Assert.Equal(count - 1, expected.Length);
90+
Assert.Equal(count == 2, action.IsSingle());
91+
index = 0;
92+
foreach (var inner in action.AsEnumerable())
93+
{
94+
Assert.Same(expected[index++], inner);
95+
}
96+
Assert.Equal(count - 1, index);
97+
98+
static void Noop() { }
99+
}
100+
101+
[Fact]
102+
public void ResetRepeatsSequence()
103+
{
104+
Action? action = Noop;
105+
action += Noop;
106+
107+
var iterator = action.GetEnumerator();
108+
Assert.True(iterator.MoveNext());
109+
Assert.True(iterator.MoveNext());
110+
Assert.False(iterator.MoveNext());
111+
112+
iterator.Reset();
113+
int count = 0;
114+
while (iterator.MoveNext()) count++;
115+
Assert.Equal(2, count);
116+
117+
static void Noop() { }
118+
}
54119
}

‎toys/AotRig/AotRig.csproj‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<!-- Minimal native-AOT rig: connects, does a few things, and exercises the event
4+
machinery (which is where AOT-hostile tricks tend to surface); see #3157. -->
5+
<PropertyGroup>
6+
<OutputType>Exe</OutputType>
7+
<!-- net8.0 as well as net10.0: the library uses a different invocation-list strategy on each
8+
(UnsafeAccessor + validation vs. the BCL's Delegate.EnumerateInvocationList) -->
9+
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
10+
<Nullable>enable</Nullable>
11+
<PublishAot>true</PublishAot>
12+
<InvariantGlobalization>true</InvariantGlobalization>
13+
<StackTraceSupport>true</StackTraceSupport>
14+
</PropertyGroup>
15+
16+
<ItemGroup>
17+
<ProjectReference Include="..\..\src\StackExchange.Redis\StackExchange.Redis.csproj" />
18+
</ItemGroup>
19+
</Project>

0 commit comments

Comments
 (0)