@@ -139,19 +139,15 @@ protected override Task DisposeCoreAsync(bool initialized)
139139 {
140140 return _semaphore . ExecuteAsync ( DisposeCoreInternalAsync ) ;
141141
142- Task DisposeCoreInternalAsync ( )
142+ async Task DisposeCoreInternalAsync ( )
143143 {
144+ List < Task > disposeTasks ;
144145 lock ( _activeSessionStates )
145146 {
146- foreach ( HotReloadSessionState sessionState in _activeSessionStates )
147- {
148- DisposeSessionStateAndStopSession ( sessionState ) ;
149- }
150-
151- _activeSessionStates . Clear ( ) ;
147+ disposeTasks = _activeSessionStates . Select ( session => session . DisposeAsync ( ) . AsTask ( ) ) . ToList ( ) ;
152148 }
153149
154- return Task . CompletedTask ;
150+ await Task . WhenAll ( disposeTasks ) ;
155151 }
156152 }
157153
@@ -210,8 +206,10 @@ async Task ActivateSessionInternalAsync()
210206 {
211207 // _pendingSessionState can be null if project doesn't support Hot Reload. i.e doesn't have SupportsHotReload capability
212208 HotReloadSessionState ? sessionState = Interlocked . Exchange ( ref _pendingSessionState , null ) ;
209+
213210 if ( sessionState is null )
214211 {
212+ DebugTrace ( "No pending session to start. Maybe the project doesn't support Hot Reload." ) ;
215213 return ;
216214 }
217215
@@ -233,54 +231,48 @@ async Task ActivateSessionInternalAsync()
233231 // process might have been exited in some cases.
234232 // in that case, we early return without starting hotreload session
235233 // one way to mimic this is to hit control + C as fast as you can once hit F5/Control + F5
236- DisposeSessionStateAndStopSession ( sessionState ) ;
234+ await sessionState . DisposeAsync ( ) ;
237235 return ;
238236 }
239237
240- process . EnableRaisingEvents = true ;
241- process . Exited += ( sender , e ) =>
238+ try
242239 {
243- DebugTrace ( "Process exited" ) ;
244- DisposeSessionStateAndStopSession ( sessionState ) ;
245- } ;
246-
247- if ( process . HasExited )
240+ process . Exited += ( sender , e ) =>
241+ {
242+ DebugTrace ( "Process exited" ) ;
243+ _threadingService . ExecuteSynchronously ( async ( ) => await sessionState . DisposeAsync ( ) ) ;
244+ } ;
245+ // If process exit before EnableRaisingEvents to true
246+ // An InvalidOperationException will be thrown
247+ process . EnableRaisingEvents = true ;
248+ // At this stage, the process will be running, and it's exit event would be captured. by the exit handler
249+ // Because
250+ // - we register the exit event before starting the session
251+ // - we set EnableRaisingEvents to true, which performs as a safeguard against missing the exit event if the process exits quickly before we register the event.
252+ await sessionState . Session . StartSessionAsync ( sessionState . CancellationToken ) ;
253+ await _projectHotReloadNotificationService . Value . SetHotReloadStateAsync ( isInHotReload : true ) ;
254+ }
255+ catch ( OperationCanceledException )
248256 {
249- DebugTrace ( "Process exited" ) ;
250- DisposeSessionStateAndStopSession ( sessionState ) ;
257+ // This can happen if CancellationToken is cancelled while starting the session.
258+ await sessionState . DisposeAsync ( ) ;
251259 }
252- else
260+ catch ( InvalidOperationException )
253261 {
254- try
255- {
256- await sessionState . Session . StartSessionAsync ( sessionState . CancellationToken ) ;
257- await _projectHotReloadNotificationService . Value . SetHotReloadStateAsync ( isInHotReload : true ) ;
258- }
259- catch ( OperationCanceledException )
260- {
261- DisposeSessionStateAndStopSession ( sessionState ) ;
262- }
262+ // This can happen if we set EnableRaisingEvents to true after the process has already exited.
263+ await sessionState . DisposeAsync ( ) ;
263264 }
264265 }
265266 }
266267
267- private void DisposeSessionStateAndStopSession ( HotReloadSessionState sessionState )
268- {
269- sessionState . Dispose ( ) ;
270-
271- // In some occasions, StopSessionAsync might be invoked before StartSessionAsync
272- // For example, if the process exits quickly after launch
273- // So we call StopSessionAsync unconditionally to ensure the session is stopped properly
274- _threadingService . ExecuteSynchronously ( ( ) => sessionState . Session . StopSessionAsync ( CancellationToken . None ) ) ;
275- }
276-
277- private sealed class HotReloadSessionState : IProjectHotReloadSessionCallback , IDisposable
268+ private sealed class HotReloadSessionState : IProjectHotReloadSessionCallback , IAsyncDisposable
278269 {
279- private int _disposed = 0 ;
280-
281270 private readonly CancellationTokenSource _cancellationTokenSource = new ( ) ;
282271 private readonly Action < HotReloadSessionState > _removeSessionState ;
283272 private readonly IProjectThreadingService _threadingService ;
273+ private readonly ReentrantSemaphore _semaphore ;
274+
275+ private int _isClosed = 0 ;
284276
285277 public HotReloadSessionState (
286278 Action < HotReloadSessionState > removeSessionState ,
@@ -289,6 +281,11 @@ public HotReloadSessionState(
289281 _removeSessionState = removeSessionState ;
290282 _threadingService = threadingService ;
291283 CancellationToken = _cancellationTokenSource . Token ;
284+
285+ _semaphore = ReentrantSemaphore . Create (
286+ initialCount : 1 ,
287+ joinableTaskContext : threadingService . JoinableTaskContext . Context ,
288+ mode : ReentrantSemaphore . ReentrancyMode . NotAllowed ) ;
292289 }
293290
294291 public CancellationToken CancellationToken { get ; }
@@ -320,71 +317,94 @@ public Task<bool> RestartProjectAsync(CancellationToken cancellationToken)
320317
321318 public async Task < bool > StopProjectAsync ( CancellationToken cancellationToken )
322319 {
323- if ( DebuggerProcess is not null && Process is not null )
324- {
325- // We have both DebuggerProcess and Process, they point to the same process. But DebuggerProcess provides a nicer way to terminate process
326- // without affecting the entire debug session.
327- // So we prefer to use DebuggerProcess to terminate the process first.
328- await TerminateProcessGracefullyAsync ( ) ;
329-
330- // When DebuggerProcess.Terminate(ignoreLaunchFlags: 1) return, the process might not be terminated
331- // So we first terminate the process nicely,
332- // Then wait for the process to exit. If the process doesn't exit within 500ms, kill it using traditional way.
333- await Process . WaitForExitAsync ( default ) . WithTimeout ( TimeSpan . FromMilliseconds ( 500 ) ) ;
334- }
335-
336- if ( Process is not null )
337- {
338- TerminateProcess ( Process ) ;
339- }
340-
341- Dispose ( ) ;
320+ await CloseSessionAsync ( stopProcess : true ) ;
342321
343322 return true ;
323+ }
324+
325+ public async ValueTask DisposeAsync ( )
326+ {
327+ await CloseSessionAsync ( stopProcess : false ) ;
328+ }
344329
345- async Task TerminateProcessGracefullyAsync ( )
330+ private async Task CloseSessionAsync ( bool stopProcess )
331+ {
332+ await _semaphore . ExecuteAsync ( async ( ) =>
346333 {
347- // Terminate DebuggerProcess need to call on UI thread
348- await _threadingService . SwitchToUIThread ( CancellationToken . None ) ;
334+ if ( Interlocked . Exchange ( ref _isClosed , 1 ) == 1 )
335+ {
336+ // Ensure we only close the session once.
337+ // Note that if multiple calls arrive with different stopProcess values, only the first will be honored.
338+ // That is ok in the context of how the session is cleaned up today.
339+ return ;
340+ }
349341
350- // Ignore the debug option launching flags since we're just terminating the process, not the entire debug session
351- // TODO consider if we can use the return value of Terminate here to control whether we need to subsequently kill the process
352- DebuggerProcess . Terminate ( ignoreLaunchFlags : 1 ) ;
353- }
342+ // Disable the exit event handler from disposing the process during our explicit shutdown sequence.
343+ // We will handle that ourselves here once we're ready.
344+ Process ? . EnableRaisingEvents = false;
354345
355- static void TerminateProcess ( Process process )
356- {
357- try
346+ if ( stopProcess )
358347 {
359- if ( ! process . HasExited )
348+ if ( DebuggerProcess is not null && Process is not null )
360349 {
361- // First try to close the process nicely and if that doesn't work kill it.
362- if ( ! process . CloseMainWindow ( ) )
363- {
364- process . Kill ( ) ;
365- }
350+ // We have both DebuggerProcess and Process, they point to the same process. But DebuggerProcess provides a nicer way to terminate process
351+ // without affecting the entire debug session.
352+ // So we prefer to use DebuggerProcess to terminate the process first.
353+
354+ await TerminateProcessGracefullyAsync ( ) ;
355+
356+ // When DebuggerProcess.Terminate(ignoreLaunchFlags: 1) return, the process might not be terminated
357+ // So we first terminate the process nicely,
358+ // Then wait for the process to exit. If the process doesn't exit within 500ms, kill it using traditional way.
359+ await Process . WaitForExitAsync ( default ) . WithTimeout ( TimeSpan . FromMilliseconds ( 500 ) ) ;
360+ }
361+
362+ if ( Process is not null )
363+ {
364+ TerminateProcess ( Process ) ;
366365 }
367366 }
368- catch ( InvalidOperationException )
369- {
370- // Process has already exited.
371- }
372- }
373- }
374367
375- public void Dispose ( )
376- {
377- if ( Interlocked . Exchange ( ref _disposed , 1 ) == 1 )
378- {
368+ // Warning
369+ // Always cancel the CancellationTokenSource ahead of StopSessionAsync
370+ _cancellationTokenSource . Cancel ( ) ;
371+ _cancellationTokenSource . Dispose ( ) ;
372+ Process ? . Dispose ( ) ;
373+
374+ // In some occasions, StopSessionAsync might be invoked before StartSessionAsync
375+ // For example, if the process exits quickly after launch
376+ // So we call StopSessionAsync unconditionally to ensure the session is stopped properly
377+ await Session . StopSessionAsync ( CancellationToken . None ) ;
378+
379+ _removeSessionState ( this ) ;
380+
379381 return ;
380- }
381382
382- _cancellationTokenSource . Cancel ( ) ;
383- _cancellationTokenSource . Dispose ( ) ;
383+ async Task TerminateProcessGracefullyAsync ( )
384+ {
385+ // Terminate DebuggerProcess need to call on UI thread
386+ await _threadingService . SwitchToUIThread ( CancellationToken . None ) ;
384387
385- Process ? . Dispose ( ) ;
388+ // Ignore the debug option launching flags since we're just terminating the process, not the entire debug session
389+ // TODO consider if we can use the return value of Terminate here to control whether we need to subsequently kill the process
390+ DebuggerProcess . Terminate ( ignoreLaunchFlags : 1 ) ;
391+ }
386392
387- _removeSessionState ( this ) ;
393+ static void TerminateProcess ( Process process )
394+ {
395+ try
396+ {
397+ if ( ! process . HasExited )
398+ {
399+ process . Kill ( ) ;
400+ }
401+ }
402+ catch ( InvalidOperationException )
403+ {
404+ // Process has already exited.
405+ }
406+ }
407+ } ) ;
388408 }
389409 }
390410
0 commit comments