Skip to content

Commit c8562e5

Browse files
Improve hotreload disposing process (#9868) (#9871)
* Handle overlapping Hot Reload session shutdown Use a semaphore in the hot reload session object to prevent overlap between our intentional process shutdown and responding to the process existed event. * Refactor Hot Reload session management to improve process exit handling and session stopping logic --------- Co-authored-by: Drew Noakes <git@drewnoakes.com>
1 parent 5c91441 commit c8562e5

File tree

2 files changed

+113
-92
lines changed

2 files changed

+113
-92
lines changed

src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/HotReload/ProjectHotReloadSessionManager.cs

Lines changed: 111 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -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

src/Microsoft.VisualStudio.ProjectSystem.Managed/ProjectSystem/HotReload/ProjectHotReloadSession.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ async Task StartSessionInternalAsync()
198198

199199
await _hotReloadAgentManagerClient.Value.AgentStartedAsync(this, flags, processInfo, runningProjectInfo, cancellationToken);
200200

201-
WriteToOutputWindow(Resources.HotReloadStartSession, cancellationToken);
201+
WriteToOutputWindow(Resources.HotReloadStartSession, default);
202202

203203
_sessionActive = true;
204204
}
@@ -212,6 +212,7 @@ async Task StopSessionInternalAsync()
212212
if (_sessionActive && _lazyDeltaApplier is not null)
213213
{
214214
_sessionActive = false;
215+
215216
_lazyDeltaApplier.Dispose();
216217
_lazyDeltaApplier = null;
217218

0 commit comments

Comments
 (0)