@@ -6,6 +6,7 @@ namespace Microsoft.UI.Reactor.Tests;
66/// <summary>
77/// Tests for UseCommand hook — sync passthrough, async lifecycle, re-entrance guards.
88/// </summary>
9+ [ Collection ( "UnobservedTaskException" ) ]
910public class UseCommandTests
1011{
1112 private static RenderContext CreateContext ( )
@@ -106,31 +107,60 @@ public async Task IsExecuting_Becomes_True_During_Execution()
106107 }
107108
108109 [ Fact ]
110+ [ Trait ( "Category" , "Threading" ) ]
109111 public async Task Error_In_ExecuteAsync_Still_Resets_IsExecuting ( )
110112 {
111113 // Use a semaphore to observe re-render requests from setIsExecuting.
112114 // Task.Yield() doesn't reliably interleave with thread pool work items
113115 // under xUnit's synchronization context.
114- var stateChanged = new SemaphoreSlim ( 0 ) ;
115- var ctx = new RenderContext ( ) ;
116- ctx . BeginRender ( ( ) => stateChanged . Release ( ) ) ;
117- var cmd = new Command
116+ int unobserved = 0 ;
117+ EventHandler < UnobservedTaskExceptionEventArgs > handler = ( _ , e ) =>
118118 {
119- Label = "Save" ,
120- ExecuteAsync = ( ) => throw new InvalidOperationException ( "test error" )
119+ if ( e . Exception . InnerExceptions . Any ( ex =>
120+ ex is InvalidOperationException { Message : "test error" } ) )
121+ {
122+ Interlocked . Increment ( ref unobserved ) ;
123+ e . SetObserved ( ) ;
124+ }
121125 } ;
126+ TaskScheduler . UnobservedTaskException += handler ;
127+ var stateChanged = new SemaphoreSlim ( 0 ) ;
128+ try
129+ {
130+ var ctx = new RenderContext ( ) ;
131+ ctx . BeginRender ( ( ) => stateChanged . Release ( ) ) ;
132+ var cmd = new Command
133+ {
134+ Label = "Save" ,
135+ ExecuteAsync = ( ) => throw new InvalidOperationException ( "test error" )
136+ } ;
122137
123- var result = ctx . UseCommand ( cmd ) ;
124- result . Execute ! ( ) ;
138+ var result = ctx . UseCommand ( cmd ) ;
139+ result . Execute ! ( ) ;
125140
126- // Execute! synchronously sets IsExecuting=true (1st release), then Task.Run
127- // catches the error and sets IsExecuting=false in finally (2nd release).
128- await stateChanged . WaitAsync ( TimeSpan . FromSeconds ( 5 ) ) ;
129- await stateChanged . WaitAsync ( TimeSpan . FromSeconds ( 5 ) ) ;
141+ // Execute! synchronously sets IsExecuting=true (1st release), then Task.Run
142+ // lets the error fault the background task while still resetting
143+ // IsExecuting=false in finally (2nd release).
144+ await stateChanged . WaitAsync ( TimeSpan . FromSeconds ( 5 ) ) ;
145+ await stateChanged . WaitAsync ( TimeSpan . FromSeconds ( 5 ) ) ;
130146
131- Rerender ( ctx ) ;
132- var result2 = ctx . UseCommand ( cmd ) ;
133- Assert . False ( result2 . IsExecuting ) ;
147+ Rerender ( ctx ) ;
148+ var result2 = ctx . UseCommand ( cmd ) ;
149+ Assert . False ( result2 . IsExecuting ) ;
150+
151+ for ( int i = 0 ; i < 10 && Volatile . Read ( ref unobserved ) == 0 ; i ++ )
152+ {
153+ GC . Collect ( ) ;
154+ GC . WaitForPendingFinalizers ( ) ;
155+ await Task . Delay ( 10 ) ;
156+ }
157+ Assert . Equal ( 1 , Volatile . Read ( ref unobserved ) ) ;
158+ }
159+ finally
160+ {
161+ TaskScheduler . UnobservedTaskException -= handler ;
162+ stateChanged . Dispose ( ) ;
163+ }
134164 }
135165
136166 // ════════════════════════════════════════════════════════════════
0 commit comments