Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Agent fixes #45369

Draft
wants to merge 4 commits into
base: release/9.0.2xx
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,11 @@
</PropertyGroup>

<ItemGroup>
<Compile Include="..\dotnet-watch\EnvironmentVariables_StartupHook.cs" Link="EnvironmentVariables_StartupHook.cs" />
<Compile Include="..\dotnet-watch\HotReload\NamedPipeContract.cs" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Extensions.DotNetDeltaApplier.Tests"/>
<InternalsVisibleTo Include="Microsoft.Extensions.DotNetDeltaApplier.Tests" />
</ItemGroup>

</Project>
241 changes: 189 additions & 52 deletions src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@
using System.IO.Pipes;
using Microsoft.DotNet.Watch;
using Microsoft.DotNet.HotReload;
using System.Diagnostics;

/// <summary>
/// The runtime startup hook looks for top-level type named "StartupHook".
/// </summary>
internal sealed class StartupHook
{
private static readonly bool s_logToStandardOutput = Environment.GetEnvironmentVariable(EnvironmentVariables.Names.HotReloadDeltaClientLogMessages) == "1";
private static readonly string s_namedPipeName = Environment.GetEnvironmentVariable(EnvironmentVariables.Names.DotnetWatchHotReloadNamedPipeName);
private static readonly string s_targetProcessPath = Environment.GetEnvironmentVariable(EnvironmentVariables.Names.DotnetWatchHotReloadTargetProcessPath);
private const int ConnectionTimeoutMS = 5000;

private static readonly bool s_logToStandardOutput = Environment.GetEnvironmentVariable(AgentEnvironmentVariables.HotReloadDeltaClientLogMessages) == "1";
private static readonly string s_namedPipeName = Environment.GetEnvironmentVariable(AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName);
private static readonly string s_targetProcessPath = Environment.GetEnvironmentVariable(AgentEnvironmentVariables.DotNetWatchHotReloadTargetProcessPath);

/// <summary>
/// Invoked by the runtime when the containing assembly is listed in DOTNET_STARTUP_HOOKS.
Expand All @@ -25,63 +28,223 @@ public static void Initialize()
// When launching the application process dotnet-watch sets Hot Reload environment variables via CLI environment directives (dotnet [env:X=Y] run).
// Currently, the CLI parser sets the env variables to the dotnet.exe process itself, rather then to the target process.
// This may cause the dotnet.exe process to connect to the named pipe and break it for the target process.
if (!IsMatchingProcess(processPath, s_targetProcessPath))
//
// Only needed when the agent is injected to the process by dotnet-watch, by the IDE.
if (s_targetProcessPath != null && !IsMatchingProcess(processPath, s_targetProcessPath))
{
Log($"Ignoring process '{processPath}', expecting '{s_targetProcessPath}'");
return;
}

Log($"Loaded into process: {processPath}");
var protocolVersion = AgentEnvironmentVariables.GetProtocolVersion(s_namedPipeName);

Log($"Process: '{processPath}', protocol version: {protocolVersion}");

HotReloadAgent.ClearHotReloadEnvironmentVariables(typeof(StartupHook));

switch (protocolVersion)
{
case 1:
// Visual Studio 17.13 P3, dotnet-watch 9.0.2xx
ProtocolV1();
break;

case 0:
// backward compat:
ProtocolV0();
break;

default:
Log($"Unsupported protocol version: {protocolVersion}.");
break;
}
}

private static void ProtocolV1()
{
Log($"Connecting to hot-reload server");

// Connect to the pipe synchronously.
//
// If a debugger is attached and there is a breakpoint in the startup code connecting asynchronously would
// set up a race between this code connecting to the server, and the breakpoint being hit. If the breakpoint
// hits first, applying changes will throw an error that the client is not connected.
//
// Updates made before the process is launched need to be applied before loading the affected modules.

var pipeClient = CreatePipe();
try
{
pipeClient.Connect(ConnectionTimeoutMS);
Log("Connected.");
}
catch (TimeoutException)
{
Log($"Failed to connect in {ConnectionTimeoutMS}ms.");
return;
}

using var agent = new HotReloadAgent();
try
{
SendCapabilities(pipeClient, agent);

ClearHotReloadEnvironmentVariables();
// Apply updates made before this process was launched to avoid executing unupdated versions of the affected modules.
// The debugger takes care of this when launching process with the debugger attached.
if (!Debugger.IsAttached)
{
ReceiveAndApplyUpdatesAsync(pipeClient, agent, initialUpdates: true, CancellationToken.None).GetAwaiter().GetResult();
}

// fire and forget:
_ = ReceiveAndApplyUpdatesAsync(pipeClient, agent, initialUpdates: false, CancellationToken.None);
}
catch (Exception ex)
{
Log(ex.Message);
pipeClient.Dispose();
}
}

private static void ProtocolV0()
{
_ = Task.Run(async () =>
{
Log($"Connecting to hot-reload server");

const int TimeOutMS = 5000;

using var pipeClient = new NamedPipeClientStream(".", s_namedPipeName, PipeDirection.InOut, PipeOptions.CurrentUserOnly | PipeOptions.Asynchronous);
using var pipeClient = CreatePipe();
try
{
await pipeClient.ConnectAsync(TimeOutMS);
await pipeClient.ConnectAsync(ConnectionTimeoutMS);
Log("Connected.");
}
catch (TimeoutException)
{
Log($"Failed to connect in {TimeOutMS}ms.");
Log($"Failed to connect in {ConnectionTimeoutMS}ms.");
return;
}

using var agent = new HotReloadAgent();
try
{
agent.Reporter.Report("Writing capabilities: " + agent.Capabilities, AgentMessageSeverity.Verbose);
SendCapabilities(pipeClient, agent);
await ReceiveAndApplyUpdatesAsync(pipeClient, agent, initialUpdates: false, CancellationToken.None);
}
catch (Exception ex)
{
Log(ex.Message);
}
});
}

private static NamedPipeClientStream CreatePipe()
=> new(".", s_namedPipeName, PipeDirection.InOut, PipeOptions.CurrentUserOnly | PipeOptions.Asynchronous);

private static void SendCapabilities(NamedPipeClientStream pipeClient, HotReloadAgent agent)
{
agent.Reporter.Report("Writing capabilities: " + agent.Capabilities, AgentMessageSeverity.Verbose);

var initPayload = new ClientInitializationPayload(agent.Capabilities);
initPayload.Write(pipeClient);
}

var initPayload = new ClientInitializationPayload(agent.Capabilities);
initPayload.Write(pipeClient);
private static async Task ReceiveAndApplyUpdatesAsync(NamedPipeClientStream pipeClient, HotReloadAgent agent, bool initialUpdates, CancellationToken cancellationToken)
{
try
{
byte[] buffer = new byte[1];

while (pipeClient.IsConnected)
while (pipeClient.IsConnected)
{
var bytesRead = await pipeClient.ReadAsync(buffer, offset: 0, count: 1, cancellationToken);
if (bytesRead != 1)
{
continue;
}

var payloadType = (PayloadType)buffer[0];
switch (payloadType)
{
var update = await UpdatePayload.ReadAsync(pipeClient, CancellationToken.None);
case PayloadType.ManagedCodeUpdate:
await ReadAndApplyManagedUpdateAsync(pipeClient, agent, cancellationToken);
break;

Log($"ResponseLoggingLevel = {update.ResponseLoggingLevel}");
case PayloadType.StaticAssetUpdate when !initialUpdates:
await ReadAndApplyStaticAssetUpdateAsync(pipeClient, agent, cancellationToken);
break;

agent.ApplyDeltas(update.Deltas);
var logEntries = agent.GetAndClearLogEntries(update.ResponseLoggingLevel);
case PayloadType.InitialUpdatesCompleted when initialUpdates:
return;

// response:
pipeClient.WriteByte(UpdatePayload.ApplySuccessValue);
UpdatePayload.WriteLog(pipeClient, logEntries);
default:
// can't continue, the pipe content is in an unknown state
Log($"Unexpected payload type: {payloadType}. Terminating agent.");
return;
}
}
catch (Exception ex)
}
catch (Exception ex)
{
Log(ex.Message);
}
finally
{
if (!pipeClient.IsConnected)
{
Log(ex.Message);
await pipeClient.DisposeAsync();
}
}
}

Log("Stopped received delta updates. Server is no longer connected.");
});
private static async Task ReadAndApplyManagedUpdateAsync(NamedPipeClientStream pipeClient, HotReloadAgent agent, CancellationToken cancellationToken)
{
var update = await UpdatePayload.ReadAsync(pipeClient, cancellationToken);

try
{
agent.ApplyDeltas(update.Deltas);
}
catch (Exception e)
{
agent.Reporter.Report(e.ToString(), AgentMessageSeverity.Error);
}

var logEntries = agent.GetAndClearLogEntries(update.ResponseLoggingLevel);

// response:
pipeClient.WriteByte(UpdatePayload.ApplySuccessValue);
UpdatePayload.WriteLog(pipeClient, logEntries);
}

private static async ValueTask ReadAndApplyStaticAssetUpdateAsync(NamedPipeClientStream pipeClient, HotReloadAgent agent, CancellationToken cancellationToken)
{
var update = await StaticAssetPayload.ReadAsync(pipeClient, default);
try
{
Log($"Attempting to apply static asset update for {update.RelativePath}.");

// TODO:
// hotReloadAgent.ApplyStaticAsset(update);

// TODO:
// Do not send a reply unless requested to do so
//if (update.ReplyExpected)
//{
// ApplyResponsePayload result = new ApplyResponsePayload { ApplySucceeded = true };
// result.Write(pipeClient);
//}
}
catch// (Exception ex)
{
// TODO:
//var errMessage = ex.GetMessageFromException();
//Log($"Update failed: {errMessage}");
//if (update.ReplyExpected)
//{
// ApplyResponsePayload result = new ApplyResponsePayload { ApplySucceeded = false, ErrorString = errMessage };
// result.Write(pipeClient);
//}
}
}

public static bool IsMatchingProcess(string processPath, string targetProcessPath)
Expand All @@ -102,32 +265,6 @@ public static bool IsMatchingProcess(string processPath, string targetProcessPat
string.Equals(processPath[..^4], targetProcessPath[..^4], comparison);
}

internal static void ClearHotReloadEnvironmentVariables()
{
// Clear any hot-reload specific environment variables. This prevents child processes from being
// affected by the current app's hot reload settings. See https://github.com/dotnet/runtime/issues/58000

Environment.SetEnvironmentVariable(EnvironmentVariables.Names.DotnetStartupHooks,
RemoveCurrentAssembly(Environment.GetEnvironmentVariable(EnvironmentVariables.Names.DotnetStartupHooks)));

Environment.SetEnvironmentVariable(EnvironmentVariables.Names.DotnetWatchHotReloadNamedPipeName, "");
Environment.SetEnvironmentVariable(EnvironmentVariables.Names.HotReloadDeltaClientLogMessages, "");
}

internal static string RemoveCurrentAssembly(string environment)
{
if (environment is "")
{
return environment;
}

var assemblyLocation = typeof(StartupHook).Assembly.Location;
var updatedValues = environment.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)
.Where(e => !string.Equals(e, assemblyLocation, StringComparison.OrdinalIgnoreCase));

return string.Join(Path.PathSeparator, updatedValues);
}

private static void Log(string message)
{
if (s_logToStandardOutput)
Expand Down
45 changes: 45 additions & 0 deletions src/BuiltInTools/HotReloadAgent/AgentEnvironmentVariables.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.DotNet.HotReload;

internal static class AgentEnvironmentVariables
{
public static readonly int CurrentPipeProtocolVersion = 1;

/// <summary>
/// Intentionally different from the variable name used by the debugger.
/// This is to avoid the debugger colliding with dotnet-watch pipe connection when debugging dotnet-watch (or tests).
/// </summary>
public const string DotNetWatchHotReloadNamedPipeName = "DOTNET_WATCH_HOTRELOAD_NAMEDPIPE_NAME";

/// <summary>
/// The full path to the process being launched by dotnet run.
/// Workaround for https://github.com/dotnet/sdk/issues/40484
/// </summary>
public const string DotNetWatchHotReloadTargetProcessPath = "DOTNET_WATCH_HOTRELOAD_TARGET_PROCESS_PATH";

/// <summary>
/// Enables logging from the client delta applier agent.
/// </summary>
public const string HotReloadDeltaClientLogMessages = "HOTRELOAD_DELTA_CLIENT_LOG_MESSAGES";

/// <summary>
/// dotnet runtime environment variable.
/// https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-environment-variables#dotnet_startup_hooks
/// </summary>
public const string DotNetStartupHooks = "DOTNET_STARTUP_HOOKS";

/// <summary>
/// dotnet runtime environment variable.
/// </summary>
public const string DotNetModifiableAssemblies = "DOTNET_MODIFIABLE_ASSEMBLIES";

public static string GenerateNamedPipeName()
=> $"{CurrentPipeProtocolVersion}_{Guid.NewGuid()}";

public static int GetProtocolVersion(string namedPipeName)
=> namedPipeName.IndexOf('_') is var index && index > 0 && ushort.TryParse(namedPipeName.AsSpan(0, index), out var version)
? version
: 0;
}
Loading
Loading