1+ using System . Collections . Concurrent ;
2+
13namespace Bunit ;
24
5+ // Invocation tracking mirrors ASP.NET Core's JSRuntime: no per-invocation state lives in instance
6+ // fields. Each call gets its own TaskCompletionSource in a ConcurrentDictionary keyed by an
7+ // Interlocked id, and the timeout closes over that entry alone, so an elapsing timeout can never
8+ // race a concurrently set result. See https://github.com/dotnet/aspnetcore/blob/main/src/JSInterop/Microsoft.JSInterop/src/JSRuntime.cs
39/// <summary>
410/// Represents an invocation handler for <see cref="JSRuntimeInvocation"/> instances.
511/// </summary>
612public abstract class JSRuntimeInvocationHandlerBase < TResult > : IDisposable
713{
814 private readonly InvocationMatcher invocationMatcher ;
9- private TaskCompletionSource < TResult > completionSource ;
10- private Timer ? timeoutTimer ;
11- private JSRuntimeInvocation ? currentInvocation ;
15+ private readonly ConcurrentDictionary < long , PendingInvocation > pendingInvocations = new ( ) ;
16+ private long nextInvocationId ;
17+ private Task < TResult > ? outcome ;
1218 private bool disposed ;
1319
1420 /// <summary>
@@ -34,48 +40,29 @@ public abstract class JSRuntimeInvocationHandlerBase<TResult> : IDisposable
3440 protected JSRuntimeInvocationHandlerBase ( InvocationMatcher matcher , bool isCatchAllHandler )
3541 {
3642 invocationMatcher = matcher ?? throw new ArgumentNullException ( nameof ( matcher ) ) ;
37- completionSource = new TaskCompletionSource < TResult > ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
3843 IsCatchAllHandler = isCatchAllHandler ;
3944 }
4045
4146 /// <summary>
4247 /// Marks the <see cref="Task{TResult}"/> that invocations will receive as canceled.
4348 /// </summary>
4449 protected void SetCanceledBase ( )
45- {
46- ClearTimeoutTimer ( ) ;
47- if ( completionSource . Task . IsCompleted )
48- completionSource = new TaskCompletionSource < TResult > ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
49-
50- completionSource . SetCanceled ( ) ;
51- }
50+ => CompleteAll ( Task . FromCanceled < TResult > ( new CancellationToken ( canceled : true ) ) ) ;
5251
5352 /// <summary>
5453 /// Sets the <typeparamref name="TException"/> exception that invocations will receive.
5554 /// </summary>
5655 /// <param name="exception">The type of exception to pass to the callers.</param>
5756 protected void SetExceptionBase < TException > ( TException exception )
5857 where TException : Exception
59- {
60- ClearTimeoutTimer ( ) ;
61- if ( completionSource . Task . IsCompleted )
62- completionSource = new TaskCompletionSource < TResult > ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
63-
64- completionSource . SetException ( exception ) ;
65- }
58+ => CompleteAll ( Task . FromException < TResult > ( exception ) ) ;
6659
6760 /// <summary>
6861 /// Sets the <typeparamref name="TResult"/> result that invocations will receive.
6962 /// </summary>
7063 /// <param name="result">The type of result to pass to the callers.</param>
7164 protected void SetResultBase ( TResult result )
72- {
73- ClearTimeoutTimer ( ) ;
74- if ( completionSource . Task . IsCompleted )
75- completionSource = new TaskCompletionSource < TResult > ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
76-
77- completionSource . SetResult ( result ) ;
78- }
65+ => CompleteAll ( Task . FromResult ( result ) ) ;
7966
8067 /// <summary>
8168 /// Call this to have the this handler handle the <paramref name="invocation"/>.
@@ -89,18 +76,29 @@ protected internal virtual Task<TResult> HandleAsync(JSRuntimeInvocation invocat
8976 {
9077 Invocations . RegisterInvocation ( invocation ) ;
9178
92- var task = completionSource . Task ;
93- if ( task is { IsCanceled : false , IsFaulted : false , IsCompletedSuccessfully : false } )
79+ if ( Volatile . Read ( ref outcome ) is { } configured )
80+ return configured ;
81+
82+ var timeout = BunitContext . DefaultWaitTimeout ;
83+ if ( timeout <= TimeSpan . Zero )
9484 {
95- if ( BunitContext . DefaultWaitTimeout <= TimeSpan . Zero )
96- {
97- throw new JSRuntimeInvocationNotSetException ( invocation ) ;
98- }
85+ throw new JSRuntimeInvocationNotSetException ( invocation ) ;
86+ }
87+
88+ var id = Interlocked . Increment ( ref nextInvocationId ) ;
89+ var pending = new PendingInvocation ( id , invocation ) ;
90+ pendingInvocations [ id ] = pending ;
9991
100- StartTimeoutTimer ( invocation ) ;
92+ if ( Volatile . Read ( ref outcome ) is { } raced && pendingInvocations . TryRemove ( id , out _ ) )
93+ {
94+ Transfer ( raced , pending . CompletionSource ) ;
95+ }
96+ else
97+ {
98+ pending . StartTimeout ( OnTimeoutElapsed , timeout ) ;
10199 }
102100
103- return task ;
101+ return pending . CompletionSource . Task ;
104102 }
105103
106104 /// <summary>
@@ -122,34 +120,71 @@ protected virtual void Dispose(bool disposing)
122120 {
123121 if ( ! disposed && disposing )
124122 {
125- ClearTimeoutTimer ( ) ;
123+ foreach ( var id in pendingInvocations . Keys )
124+ {
125+ if ( pendingInvocations . TryRemove ( id , out var pending ) )
126+ pending . Dispose ( ) ;
127+ }
128+
126129 disposed = true ;
127130 }
128131 }
129132
130- private void StartTimeoutTimer ( JSRuntimeInvocation invocation )
133+ private void CompleteAll ( Task < TResult > next )
131134 {
132- ClearTimeoutTimer ( ) ;
135+ Volatile . Write ( ref outcome , next ) ;
133136
134- currentInvocation = invocation ;
135- timeoutTimer = new Timer ( OnTimeoutElapsed , null , BunitContext . DefaultWaitTimeout , Timeout . InfiniteTimeSpan ) ;
137+ foreach ( var id in pendingInvocations . Keys )
138+ {
139+ if ( pendingInvocations . TryRemove ( id , out var pending ) )
140+ {
141+ pending . Dispose ( ) ;
142+ Transfer ( next , pending . CompletionSource ) ;
143+ }
144+ }
136145 }
137146
138- private void ClearTimeoutTimer ( )
147+ private void OnTimeoutElapsed ( object ? state )
139148 {
140- timeoutTimer ? . Dispose ( ) ;
141- timeoutTimer = null ;
142- currentInvocation = null ;
149+ if ( state is not PendingInvocation pending || ! pendingInvocations . TryRemove ( pending . Id , out _ ) )
150+ return ;
151+
152+ pending . Dispose ( ) ;
153+ pending . CompletionSource . TrySetException ( new JSRuntimeInvocationNotSetException ( pending . Invocation ) ) ;
143154 }
144155
145- private void OnTimeoutElapsed ( object ? state )
156+ private static void Transfer ( Task < TResult > from , TaskCompletionSource < TResult > to )
146157 {
147- if ( ! completionSource . Task . IsCompleted && currentInvocation . HasValue )
158+ if ( from . IsCanceled )
159+ to . TrySetCanceled ( ) ;
160+ else if ( from . Exception is { } exception )
161+ to . TrySetException ( exception . InnerExceptions ) ;
162+ else
163+ to . TrySetResult ( from . Result ) ;
164+ }
165+
166+ private sealed class PendingInvocation : IDisposable
167+ {
168+ private Timer ? timeoutTimer ;
169+
170+ public long Id { get ; }
171+
172+ public JSRuntimeInvocation Invocation { get ; }
173+
174+ public TaskCompletionSource < TResult > CompletionSource { get ; } = new ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
175+
176+ public PendingInvocation ( long id , JSRuntimeInvocation invocation )
177+ {
178+ Id = id ;
179+ Invocation = invocation ;
180+ }
181+
182+ public void StartTimeout ( TimerCallback callback , TimeSpan timeout )
148183 {
149- var exception = new JSRuntimeInvocationNotSetException ( currentInvocation . Value ) ;
150- completionSource . TrySetException ( exception ) ;
184+ timeoutTimer = new Timer ( callback , this , Timeout . InfiniteTimeSpan , Timeout . InfiniteTimeSpan ) ;
185+ timeoutTimer . Change ( timeout , Timeout . InfiniteTimeSpan ) ;
151186 }
152187
153- ClearTimeoutTimer ( ) ;
188+ public void Dispose ( ) => timeoutTimer ? . Dispose ( ) ;
154189 }
155190}
0 commit comments