Skip to content

Commit fc261e5

Browse files
committed
Notify DCP of terminated session when process exits on its own
1 parent 12f56dc commit fc261e5

File tree

24 files changed

+342
-181
lines changed

24 files changed

+342
-181
lines changed

src/BuiltInTools/AspireService/AspireServerService.cs

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ public List<KeyValuePair<string, string>> GetServerConnectionEnvironment()
123123
new(DebugSessionServerCertEnvVar, _certificateEncodedBytes),
124124
];
125125

126+
/// <exception cref="OperationCanceledException"/>
126127
public ValueTask NotifySessionEndedAsync(string dcpId, string sessionId, int processId, int? exitCode, CancellationToken cancelationToken)
127128
=> SendNotificationAsync(
128129
new SessionTerminatedNotification()
@@ -136,6 +137,7 @@ public ValueTask NotifySessionEndedAsync(string dcpId, string sessionId, int pro
136137
sessionId,
137138
cancelationToken);
138139

140+
/// <exception cref="OperationCanceledException"/>
139141
public ValueTask NotifySessionStartedAsync(string dcpId, string sessionId, int processId, CancellationToken cancelationToken)
140142
=> SendNotificationAsync(
141143
new ProcessRestartedNotification()
@@ -148,6 +150,7 @@ public ValueTask NotifySessionStartedAsync(string dcpId, string sessionId, int p
148150
sessionId,
149151
cancelationToken);
150152

153+
/// <exception cref="OperationCanceledException"/>
151154
public ValueTask NotifyLogMessageAsync(string dcpId, string sessionId, bool isStdErr, string data, CancellationToken cancelationToken)
152155
=> SendNotificationAsync(
153156
new ServiceLogsNotification()
@@ -161,23 +164,28 @@ public ValueTask NotifyLogMessageAsync(string dcpId, string sessionId, bool isSt
161164
sessionId,
162165
cancelationToken);
163166

164-
private async ValueTask SendNotificationAsync<TNotification>(TNotification notification, string dcpId, string sessionId, CancellationToken cancelationToken)
167+
/// <exception cref="OperationCanceledException"/>
168+
private async ValueTask SendNotificationAsync<TNotification>(TNotification notification, string dcpId, string sessionId, CancellationToken cancellationToken)
165169
where TNotification : SessionNotification
166170
{
167171
try
168172
{
169-
Log($"[#{sessionId}] Sending '{notification.NotificationType}'");
173+
Log($"[#{sessionId}] Sending '{notification.NotificationType}': {notification}");
170174
var jsonSerialized = JsonSerializer.SerializeToUtf8Bytes(notification, JsonSerializerOptions);
171-
await SendMessageAsync(dcpId, jsonSerialized, cancelationToken);
172-
}
173-
catch (Exception e) when (e is not OperationCanceledException && LogAndPropagate(e))
174-
{
175-
}
175+
var success = await SendMessageAsync(dcpId, jsonSerialized, cancellationToken);
176176

177-
bool LogAndPropagate(Exception e)
177+
if (!success)
178+
{
179+
cancellationToken.ThrowIfCancellationRequested();
180+
Log($"[#{sessionId}] Failed to send message: Connection not found (dcpId='{dcpId}').");
181+
}
182+
}
183+
catch (Exception e) when (e is not OperationCanceledException)
178184
{
179-
Log($"[#{sessionId}] Sending '{notification.NotificationType}' failed: {e.Message}");
180-
return false;
185+
if (!cancellationToken.IsCancellationRequested)
186+
{
187+
Log($"[#{sessionId}] Failed to send message: {e.Message}");
188+
}
181189
}
182190
}
183191

@@ -373,15 +381,13 @@ private async Task WriteResponseTextAsync(HttpResponse response, Exception ex, b
373381
}
374382
}
375383

376-
private async Task SendMessageAsync(string dcpId, byte[] messageBytes, CancellationToken cancellationToken)
384+
private async ValueTask<bool> SendMessageAsync(string dcpId, byte[] messageBytes, CancellationToken cancellationToken)
377385
{
378386
// Find the connection for the passed in dcpId
379387
WebSocketConnection? connection = _socketConnectionManager.GetSocketConnection(dcpId);
380388
if (connection is null)
381389
{
382-
// Most likely the connection has already gone away
383-
Log($"Send message failure: Connection with the following dcpId was not found {dcpId}");
384-
return;
390+
return false;
385391
}
386392

387393
var success = false;
@@ -405,6 +411,8 @@ private async Task SendMessageAsync(string dcpId, byte[] messageBytes, Cancellat
405411

406412
_webSocketAccess.Release();
407413
}
414+
415+
return success;
408416
}
409417

410418
private async ValueTask HandleStopSessionRequestAsync(HttpContext context, string sessionId)

src/BuiltInTools/AspireService/Models/SessionChangeNotification.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ internal sealed class SessionTerminatedNotification : SessionNotification
5656
[Required]
5757
[JsonPropertyName("exit_code")]
5858
public required int? ExitCode { get; init; }
59+
60+
public override string ToString()
61+
=> $"pid={Pid}, exit_code={ExitCode}";
5962
}
6063

6164
/// <summary>
@@ -70,6 +73,9 @@ internal sealed class ProcessRestartedNotification : SessionNotification
7073
[Required]
7174
[JsonPropertyName("pid")]
7275
public required int PID { get; init; }
76+
77+
public override string ToString()
78+
=> $"pid={PID}";
7379
}
7480

7581
/// <summary>
@@ -91,4 +97,7 @@ internal sealed class ServiceLogsNotification : SessionNotification
9197
[Required]
9298
[JsonPropertyName("log_message")]
9399
public required string LogMessage { get; init; }
100+
101+
public override string ToString()
102+
=> $"log_message='{LogMessage}', is_std_err={IsStdErr}";
94103
}

src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,10 @@ private Task<ImmutableArray<string>> GetCapabilitiesTask()
9696

9797
[MemberNotNull(nameof(_pipe))]
9898
[MemberNotNull(nameof(_capabilitiesTask))]
99-
private void RequireReadyForUpdates()
99+
private void RequireReadyForUpdates(CancellationToken cancellationToken)
100100
{
101+
cancellationToken.ThrowIfCancellationRequested();
102+
101103
// should only be called after connection has been created:
102104
_ = GetCapabilitiesTask();
103105

@@ -126,7 +128,7 @@ private ResponseLoggingLevel ResponseLoggingLevel
126128

127129
public override async Task<ApplyStatus> ApplyManagedCodeUpdatesAsync(ImmutableArray<HotReloadManagedCodeUpdate> updates, bool isProcessSuspended, CancellationToken cancellationToken)
128130
{
129-
RequireReadyForUpdates();
131+
RequireReadyForUpdates(cancellationToken);
130132

131133
if (_managedCodeUpdateFailedOrCancelled)
132134
{
@@ -152,7 +154,12 @@ public override async Task<ApplyStatus> ApplyManagedCodeUpdatesAsync(ImmutableAr
152154
{
153155
if (!success)
154156
{
155-
Logger.LogWarning("Further changes won't be applied to this process.");
157+
// Don't report a warning when cancelled. The process has terminated or the host is shutting down in that case.
158+
if (!cancellationToken.IsCancellationRequested)
159+
{
160+
Logger.LogWarning("Further changes won't be applied to this process.");
161+
}
162+
156163
_managedCodeUpdateFailedOrCancelled = true;
157164
DisposePipe();
158165
}
@@ -183,7 +190,7 @@ public async override Task<ApplyStatus> ApplyStaticAssetUpdatesAsync(ImmutableAr
183190
return ApplyStatus.AllChangesApplied;
184191
}
185192

186-
RequireReadyForUpdates();
193+
RequireReadyForUpdates(cancellationToken);
187194

188195
var appliedUpdateCount = 0;
189196

@@ -241,7 +248,7 @@ async ValueTask<bool> SendAndReceiveAsync(int batchId, CancellationToken cancell
241248

242249
Logger.LogDebug("Update batch #{UpdateId} failed.", batchId);
243250
}
244-
catch (Exception e) when (e is not OperationCanceledException || isProcessSuspended)
251+
catch (Exception e)
245252
{
246253
if (cancellationToken.IsCancellationRequested)
247254
{
@@ -282,7 +289,7 @@ private async ValueTask<bool> ReceiveUpdateResponseAsync(CancellationToken cance
282289

283290
public override async Task InitialUpdatesAppliedAsync(CancellationToken cancellationToken)
284291
{
285-
RequireReadyForUpdates();
292+
RequireReadyForUpdates(cancellationToken);
286293

287294
if (_managedCodeUpdateFailedOrCancelled)
288295
{
@@ -299,7 +306,7 @@ public override async Task InitialUpdatesAppliedAsync(CancellationToken cancella
299306
// pipe might throw another exception when forcibly closed on process termination:
300307
if (!cancellationToken.IsCancellationRequested)
301308
{
302-
Logger.LogError("Failed to send InitialUpdatesCompleted: {Message}", e.Message);
309+
Logger.LogError("Failed to send {RequestType}: {Message}", nameof(RequestType.InitialUpdatesCompleted), e.Message);
303310
}
304311
}
305312
}

src/BuiltInTools/dotnet-watch/Aspire/AspireServiceFactory.cs

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ private readonly struct Session(string dcpId, string sessionId, RunningProject r
4343

4444
private readonly Dictionary<string, Session> _sessions = [];
4545
private int _sessionIdDispenser;
46+
4647
private volatile bool _isDisposed;
4748

4849
public SessionManager(ProjectLauncher projectLauncher, ProjectOptions hostProjectOptions)
@@ -82,10 +83,7 @@ public async ValueTask TerminateLaunchedProcessesAsync(CancellationToken cancell
8283
_sessions.Clear();
8384
}
8485

85-
foreach (var session in sessions)
86-
{
87-
await TerminateSessionAsync(session, cancellationToken);
88-
}
86+
await Task.WhenAll(sessions.Select(TerminateSessionAsync)).WaitAsync(cancellationToken);
8987
}
9088

9189
public IEnumerable<(string name, string value)> GetEnvironmentVariables()
@@ -113,14 +111,31 @@ public async ValueTask<RunningProject> StartProjectAsync(string dcpId, string se
113111
var processTerminationSource = new CancellationTokenSource();
114112
var outputChannel = Channel.CreateUnbounded<OutputLine>(s_outputChannelOptions);
115113

116-
var runningProject = await _projectLauncher.TryLaunchProcessAsync(
114+
RunningProject? runningProject = null;
115+
116+
runningProject = await _projectLauncher.TryLaunchProcessAsync(
117117
projectOptions,
118118
processTerminationSource,
119119
onOutput: line =>
120120
{
121121
var writeResult = outputChannel.Writer.TryWrite(line);
122122
Debug.Assert(writeResult);
123123
},
124+
onExit: async (processId, exitCode) =>
125+
{
126+
// Project can be null if the process exists while its being initialized.
127+
if (runningProject?.IsRestarting == false)
128+
{
129+
try
130+
{
131+
await _service.NotifySessionEndedAsync(dcpId, sessionId, processId, exitCode, cancellationToken);
132+
}
133+
catch (OperationCanceledException)
134+
{
135+
// canceled on shutdown, ignore
136+
}
137+
}
138+
},
124139
restartOperation: cancellationToken =>
125140
StartProjectAsync(dcpId, sessionId, projectOptions, isRestart: true, cancellationToken),
126141
cancellationToken);
@@ -134,7 +149,7 @@ public async ValueTask<RunningProject> StartProjectAsync(string dcpId, string se
134149
await _service.NotifySessionStartedAsync(dcpId, sessionId, runningProject.ProcessId, cancellationToken);
135150

136151
// cancel reading output when the process terminates:
137-
var outputReader = StartChannelReader(processTerminationSource.Token);
152+
var outputReader = StartChannelReader(runningProject.ProcessExitedSource.Token);
138153

139154
lock (_guard)
140155
{
@@ -159,7 +174,7 @@ async Task StartChannelReader(CancellationToken cancellationToken)
159174
}
160175
catch (Exception e)
161176
{
162-
if (e is not OperationCanceledException)
177+
if (!cancellationToken.IsCancellationRequested)
163178
{
164179
_logger.LogError("Unexpected error reading output of session '{SessionId}': {Exception}", sessionId, e);
165180
}
@@ -185,18 +200,15 @@ async ValueTask<bool> IAspireServerEvents.StopSessionAsync(string dcpId, string
185200
_sessions.Remove(sessionId);
186201
}
187202

188-
await TerminateSessionAsync(session, cancellationToken);
203+
await TerminateSessionAsync(session);
189204
return true;
190205
}
191206

192-
private async ValueTask TerminateSessionAsync(Session session, CancellationToken cancellationToken)
207+
private async Task TerminateSessionAsync(Session session)
193208
{
194209
_logger.LogDebug("Stop session #{SessionId}", session.Id);
195210

196-
var exitCode = await _projectLauncher.TerminateProcessAsync(session.RunningProject, cancellationToken);
197-
198-
// Wait until the started notification has been sent so that we don't send out of order notifications:
199-
await _service.NotifySessionEndedAsync(session.DcpId, session.Id, session.RunningProject.ProcessId, exitCode, cancellationToken);
211+
await session.RunningProject.TerminateAsync(isRestarting: false);
200212

201213
// process termination should cancel output reader task:
202214
await session.OutputReader;

0 commit comments

Comments
 (0)