Skip to content

Commit

Permalink
Notifications 1
Browse files Browse the repository at this point in the history
  • Loading branch information
tmat committed Oct 22, 2024
1 parent cddad25 commit ef3df33
Show file tree
Hide file tree
Showing 13 changed files with 176 additions and 45 deletions.
2 changes: 1 addition & 1 deletion src/BuiltInTools/AspireService/AspireServerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ public List<KeyValuePair<string, string>> GetServerConnectionEnvironment()
new(DebugSessionServerCertEnvVar, _certificateEncodedBytes),
];

public ValueTask NotifySessionEndedAsync(string dcpId, string sessionId, int processId, int exitCode, CancellationToken cancelationToken)
public ValueTask NotifySessionEndedAsync(string dcpId, string sessionId, int processId, int? exitCode, CancellationToken cancelationToken)
=> SendNotificationAsync(
new SessionTerminatedNotification()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ internal interface IAspireServerEvents
/// Called when a request to stop a session is received.
/// </summary>
/// <param name="dcpId">DCP/AppHost making the request. May be empty for older DCP versions.</param>
ValueTask StopSessionAsync(string dcpId, string sessionId, CancellationToken cancelToken);
ValueTask StopSessionAsync(string dcpId, string sessionId, CancellationToken cancellationToken);

/// <summary>
/// Called when a request to start a project is received. Returns the sessionId of the started project.
/// </summary>
/// <param name="dcpId">DCP/AppHost making the request. May be empty for older DCP versions.</param>
ValueTask<string> StartProjectAsync(string dcpId, ProjectLaunchRequest projectLaunchInfo, CancellationToken cancelToken);
ValueTask<string> StartProjectAsync(string dcpId, ProjectLaunchRequest projectLaunchInfo, CancellationToken cancellationToken);
}

internal class ProjectLaunchRequest
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ internal sealed class SessionTerminatedNotification : SessionNotification
/// </summary>
[Required]
[JsonPropertyName("exit_code")]
public required int ExitCode { get; init; }
public required int? ExitCode { get; init; }
}

/// <summary>
Expand Down
74 changes: 62 additions & 12 deletions src/BuiltInTools/dotnet-watch/Aspire/AspireServiceFactory.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;
using System.Diagnostics.Eventing.Reader;
using System.Globalization;
using Microsoft.Build.Graph;
using Microsoft.CodeAnalysis.RemoveUnnecessaryImports;
using Microsoft.DotNet.Watcher.Tools;
using Microsoft.Extensions.Tools.Internal;
using Microsoft.WebTools.AspireServer;
Expand All @@ -14,6 +17,8 @@ internal class AspireServiceFactory : IRuntimeProcessLauncherFactory
{
private sealed class ServerEvents(ProjectLauncher projectLauncher, IReadOnlyList<(string name, string value)> buildProperties) : IAspireServerEvents
{
private AspireServerService? _lazyService;

/// <summary>
/// Lock to access:
/// <see cref="_sessions"/>
Expand All @@ -27,6 +32,19 @@ private sealed class ServerEvents(ProjectLauncher projectLauncher, IReadOnlyList
private IReporter Reporter
=> projectLauncher.Reporter;

public AspireServerService GetService()
=> _lazyService ?? throw new InvalidOperationException();

public void InitializeService(AspireServerService service)
{
if (_lazyService != null)
{
throw new InvalidOperationException();
}

_lazyService = service;
}

/// <summary>
/// Implements https://github.com/dotnet/aspire/blob/445d2fc8a6a0b7ce3d8cc42def4d37b02709043b/docs/specs/IDE-execution.md#create-session-request.
/// </summary>
Expand All @@ -38,17 +56,45 @@ public async ValueTask<string> StartProjectAsync(string dcpId, ProjectLaunchRequ

var processTerminationSource = new CancellationTokenSource();

var runningProject = await projectLauncher.TryLaunchProcessAsync(projectOptions, processTerminationSource, build: false, cancellationToken);
if (runningProject == null)
var sessionId = Interlocked.Increment(ref _sessionIdDispenser).ToString(CultureInfo.InvariantCulture);

var notificationSemaphore = new TaskCompletionSource<bool>();

var startedNotificationSent = false;
RunningProject? runningProject;
try
{
// detailed error already reported:
throw new ApplicationException($"Failed to launch project '{projectLaunchInfo.ProjectPath}'.");
runningProject = await projectLauncher.TryLaunchProcessAsync(
projectOptions,
processTerminationSource,
onExit: async (processId, exitCode) =>
{
// Wait until the started notification has been sent so that we don't send out of order notifications:
if (await notificationSemaphore.Task)
{
await GetService().NotifySessionEndedAsync(dcpId, sessionId, processId, exitCode, cancellationToken);
}
},
build: false,
cancellationToken);

if (runningProject == null)
{
// detailed error already reported:
throw new ApplicationException($"Failed to launch project '{projectLaunchInfo.ProjectPath}'.");
}

await GetService().NotifySessionStartedAsync(dcpId, sessionId, runningProject.ProcessId, cancellationToken);
startedNotificationSent = true;
}
finally
{
// unblock onExit:
notificationSemaphore.TrySetResult(startedNotificationSent);
}

string sessionId;
lock (_guard)
{
sessionId = _sessionIdDispenser++.ToString(CultureInfo.InvariantCulture);
_sessions.Add(sessionId, runningProject);
}

Expand All @@ -63,14 +109,14 @@ public async ValueTask StopSessionAsync(string dcpId, string sessionId, Cancella
{
Reporter.Verbose($"Stop Session {sessionId}", MessageEmoji);

RunningProject? runningProject;
RunningProject runningProject;
lock (_guard)
{
runningProject = _sessions[sessionId];
_sessions.Remove(sessionId);
}

_ = await projectLauncher.TerminateProcessesAsync([runningProject.ProjectNode.ProjectInstance.FullPath], cancellationToken);
await projectLauncher.TerminateProcessAsync(runningProject, cancellationToken);
}

private ProjectOptions GetProjectOptions(ProjectLaunchRequest projectLaunchInfo)
Expand Down Expand Up @@ -127,9 +173,13 @@ private ProjectOptions GetProjectOptions(ProjectLaunchRequest projectLaunchInfo)
return null;
}

// TODO: implement notifications:
// 1) Process restarted notification
// 2) Session terminated notification
return new AspireServerService(new ServerEvents(projectLauncher, buildProperties), displayName: ".NET Watch Aspire Server", m => projectLauncher.Reporter.Verbose(m, MessageEmoji));
var events = new ServerEvents(projectLauncher, buildProperties);
var service = new AspireServerService(
events,
displayName: ".NET Watch Aspire Server",
m => projectLauncher.Reporter.Verbose(m, MessageEmoji));

events.InitializeService(service);
return service;
}
}
2 changes: 1 addition & 1 deletion src/BuiltInTools/dotnet-watch/DotNetWatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ public override async Task WatchAsync(CancellationToken cancellationToken)
using var combinedCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, currentRunCancellationSource.Token);
using var fileSetWatcher = new FileWatcher(evaluationResult.Files, Context.Reporter);

var processTask = ProcessRunner.RunAsync(processSpec, Context.Reporter, isUserApplication: true, processExitedSource: null, combinedCancellationSource.Token);
var processTask = ProcessRunner.RunAsync(processSpec, Context.Reporter, isUserApplication: true, launchResult: null, combinedCancellationSource.Token);

Task<ChangedFile?> fileSetTask;
Task finishedTask;
Expand Down
76 changes: 61 additions & 15 deletions src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.EditAndContinue;
using Microsoft.CodeAnalysis.ExternalAccess.Watch.Api;
using Microsoft.CodeAnalysis.Text;
using Microsoft.DotNet.Watcher.Internal;
using Microsoft.Extensions.Tools.Internal;

Expand Down Expand Up @@ -80,7 +79,7 @@ private static async ValueTask TerminateAndDisposeRunningProjects(IEnumerable<Ru
}

// wait for all tasks to complete:
await Task.WhenAll(projects.Select(p => p.RunningProcess)).WaitAsync(CancellationToken.None);
var exitCodes = await Task.WhenAll(projects.Select(p => p.RunningProcess)).WaitAsync(CancellationToken.None);

// dispose only after all tasks have completed to prevent the tasks from accessing disposed resources:
foreach (var project in projects)
Expand Down Expand Up @@ -146,7 +145,17 @@ public async Task<RunningProject> TrackRunningProjectAsync(
// It is important to first create the named pipe connection (delta applier is the server)
// and then start the process (named pipe client). Otherwise, the connection would fail.
deltaApplier.CreateConnection(namedPipeName, processCommunicationCancellationSource.Token);
var runningProcess = ProcessRunner.RunAsync(processSpec, processReporter, isUserApplication: true, processExitedSource, processTerminationSource.Token);
var launchResult = new ProcessLaunchResult();
processSpec.OnExit = (_, _) =>
{
processExitedSource.Cancel();
return ValueTask.CompletedTask;
};

var runningProcess = ProcessRunner.RunAsync(processSpec, processReporter, isUserApplication: true, launchResult, processTerminationSource.Token);

// RunAsync throws if process can't be launched, otherwise we must have a PID.
Debug.Assert(launchResult.ProcessId != null);

var capabilityProvider = deltaApplier.GetApplyUpdateCapabilitiesAsync(processCommunicationCancellationSource.Token);
var runningProject = new RunningProject(
Expand All @@ -156,6 +165,7 @@ public async Task<RunningProject> TrackRunningProjectAsync(
processReporter,
browserRefreshServer,
runningProcess,
launchResult.ProcessId.Value,
processExitedSource: processExitedSource,
processTerminationSource: processTerminationSource,
disposables: [processCommunicationCancellationSource],
Expand Down Expand Up @@ -439,22 +449,17 @@ await ForEachProjectAsync(
/// Terminates all processes launched for projects with <paramref name="projectPaths"/>.
/// Removes corresponding entries from <see cref="_runningProjects"/>.
///
/// May terminate the root project process as well.
/// Does not terminate the root project.
/// </summary>
internal async ValueTask<IEnumerable<RunningProject>> TerminateNonRootProcessesAsync(IEnumerable<string> projectPaths, CancellationToken cancellationToken)
{
IEnumerable<RunningProject> projectsToRestart;
lock (_runningProjectsAndUpdatesGuard)
{
// capture snapshot of running processes that can be enumerated outside of the lock:
var runningProjects = _runningProjects;
projectsToRestart = projectPaths.SelectMany(path => runningProjects[path]);
IEnumerable<RunningProject> projectsToRestart = [];

_runningProjects = runningProjects.RemoveRange(projectPaths);

// reset capabilities:
_currentAggregateCapabilities = default;
}
UpdateRunningProjects(runningProjectsByPath =>
{
projectsToRestart = projectPaths.SelectMany(path => _runningProjects.TryGetValue(path, out var array) ? array : []);
return runningProjectsByPath.RemoveRange(projectPaths);
});

// Do not terminate root process at this time - it would signal the cancellation token we are currently using.
// The process will be restarted later on.
Expand All @@ -466,6 +471,47 @@ internal async ValueTask<IEnumerable<RunningProject>> TerminateNonRootProcessesA
return projectsToRestart;
}

/// <summary>
/// Terminates process of the given <paramref name="project"/>.
/// Removes corresponding entries from <see cref="_runningProjects"/>.
///
/// Should not be called with the root project.
/// </summary>
internal async ValueTask TerminateNonRootProcessAsync(RunningProject project, CancellationToken cancellationToken)
{
Debug.Assert(!project.Options.IsRootProject);

var projectPath = project.ProjectNode.ProjectInstance.FullPath;

UpdateRunningProjects(runningProjectsByPath =>
{
if (!runningProjectsByPath.TryGetValue(projectPath, out var runningProjects) ||
runningProjects.Remove(project) is var updatedRunningProjects && runningProjects == updatedRunningProjects)
{
_reporter.Verbose($"Ignoring an attempt to terminate process {project.ProcessId} of project '{projectPath}' that has no associated running processes.");
return runningProjectsByPath;
}
return updatedRunningProjects is []
? runningProjectsByPath.Remove(projectPath)
: runningProjectsByPath.SetItem(projectPath, updatedRunningProjects);
});

// wait for all processes to exit to release their resources:
await TerminateAndDisposeRunningProjects([project]);
}

private void UpdateRunningProjects(Func<ImmutableDictionary<string, ImmutableArray<RunningProject>>, ImmutableDictionary<string, ImmutableArray<RunningProject>>> updater)
{
lock (_runningProjectsAndUpdatesGuard)
{
_runningProjects = updater(_runningProjects);

// reset capabilities:
_currentAggregateCapabilities = default;
}
}

private static Task ForEachProjectAsync(ImmutableDictionary<string, ImmutableArray<RunningProject>> projects, Func<RunningProject, CancellationToken, Task> action, CancellationToken cancellationToken)
=> Task.WhenAll(projects.SelectMany(entry => entry.Value).Select(project => action(project, cancellationToken))).WaitAsync(cancellationToken);
}
Expand Down
16 changes: 13 additions & 3 deletions src/BuiltInTools/dotnet-watch/HotReload/ProjectLauncher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public IReporter Reporter
public EnvironmentOptions EnvironmentOptions
=> context.EnvironmentOptions;

public async ValueTask<RunningProject?> TryLaunchProcessAsync(ProjectOptions projectOptions, CancellationTokenSource processTerminationSource, bool build, CancellationToken cancellationToken)
public async ValueTask<RunningProject?> TryLaunchProcessAsync(ProjectOptions projectOptions, CancellationTokenSource processTerminationSource, Func<int, int?, ValueTask>? onExit, bool build, CancellationToken cancellationToken)
{
var projectNode = projectMap.TryGetProjectNode(projectOptions.ProjectPath, projectOptions.TargetFramework);
if (projectNode == null)
Expand All @@ -40,7 +40,7 @@ public EnvironmentOptions EnvironmentOptions

try
{
return await LaunchProcessAsync(projectOptions, projectNode, processTerminationSource, build, cancellationToken);
return await LaunchProcessAsync(projectOptions, projectNode, processTerminationSource, onExit, build, cancellationToken);
}
catch (ObjectDisposedException e) when (e.ObjectName == typeof(HotReloadDotNetWatcher).FullName)
{
Expand All @@ -49,12 +49,19 @@ public EnvironmentOptions EnvironmentOptions
}
}

public async Task<RunningProject> LaunchProcessAsync(ProjectOptions projectOptions, ProjectGraphNode projectNode, CancellationTokenSource processTerminationSource, bool build, CancellationToken cancellationToken)
public async Task<RunningProject> LaunchProcessAsync(
ProjectOptions projectOptions,
ProjectGraphNode projectNode,
CancellationTokenSource processTerminationSource,
Func<int, int?, ValueTask>? onExit,
bool build,
CancellationToken cancellationToken)
{
var processSpec = new ProcessSpec
{
Executable = EnvironmentOptions.MuxerPath,
WorkingDirectory = projectOptions.WorkingDirectory,
OnExit = onExit,
Arguments = build || projectOptions.Command is not ("run" or "test")
? [projectOptions.Command, .. projectOptions.CommandArguments]
: [projectOptions.Command, "--no-build", .. projectOptions.CommandArguments]
Expand Down Expand Up @@ -119,4 +126,7 @@ public async Task<RunningProject> LaunchProcessAsync(ProjectOptions projectOptio

public ValueTask<IEnumerable<RunningProject>> TerminateProcessesAsync(IReadOnlyList<string> projectPaths, CancellationToken cancellationToken)
=> compilationHandler.TerminateNonRootProcessesAsync(projectPaths, cancellationToken);

public ValueTask TerminateProcessAsync(RunningProject project, CancellationToken cancellationToken)
=> compilationHandler.TerminateNonRootProcessAsync(project, cancellationToken);
}
6 changes: 4 additions & 2 deletions src/BuiltInTools/dotnet-watch/HotReload/RunningProject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ internal sealed class RunningProject(
DeltaApplier deltaApplier,
IReporter reporter,
BrowserRefreshServer? browserRefreshServer,
Task runningProcess,
Task<int> runningProcess,
int processId,
CancellationTokenSource processExitedSource,
CancellationTokenSource processTerminationSource,
IReadOnlyList<IDisposable> disposables,
Expand All @@ -26,7 +27,8 @@ internal sealed class RunningProject(
public readonly DeltaApplier DeltaApplier = deltaApplier;
public readonly Task<ImmutableArray<string>> CapabilityProvider = capabilityProvider;
public readonly IReporter Reporter = reporter;
public readonly Task RunningProcess = runningProcess;
public readonly Task<int> RunningProcess = runningProcess;
public readonly int ProcessId = processId;

/// <summary>
/// Cancellation source triggered when the process exits.
Expand Down
Loading

0 comments on commit ef3df33

Please sign in to comment.