Skip to content

Commit f0319e5

Browse files
authored
[dotnet-watch] Auto-restart project on runtime rude edit (#51073)
1 parent ec8d85f commit f0319e5

File tree

17 files changed

+444
-71
lines changed

17 files changed

+444
-71
lines changed

src/BuiltInTools/DotNetDeltaApplier/PipeListener.cs

Lines changed: 56 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Diagnostics;
45
using System.IO.Pipes;
56
using System.Reflection;
67
using System.Runtime.Loader;
@@ -9,6 +10,17 @@ namespace Microsoft.DotNet.HotReload;
910

1011
internal sealed class PipeListener(string pipeName, IHotReloadAgent agent, Action<string> log, int connectionTimeoutMS = 5000)
1112
{
13+
/// <summary>
14+
/// Messages to the client sent after the initial <see cref="ClientInitializationResponse"/> is sent
15+
/// need to be sent while holding this lock in order to synchronize
16+
/// 1) responses to requests received from the client (e.g. <see cref="UpdateResponse"/>) or
17+
/// 2) notifications sent to the client that may be triggered at arbitrary times (e.g. <see cref="HotReloadExceptionCreatedNotification"/>).
18+
/// </summary>
19+
private readonly SemaphoreSlim _messageToClientLock = new(initialCount: 1);
20+
21+
// Not-null once initialized:
22+
private NamedPipeClientStream? _pipeClient;
23+
1224
public Task Listen(CancellationToken cancellationToken)
1325
{
1426
// Connect to the pipe synchronously.
@@ -21,23 +33,23 @@ public Task Listen(CancellationToken cancellationToken)
2133

2234
log($"Connecting to hot-reload server via pipe {pipeName}");
2335

24-
var pipeClient = new NamedPipeClientStream(serverName: ".", pipeName, PipeDirection.InOut, PipeOptions.CurrentUserOnly | PipeOptions.Asynchronous);
36+
_pipeClient = new NamedPipeClientStream(serverName: ".", pipeName, PipeDirection.InOut, PipeOptions.CurrentUserOnly | PipeOptions.Asynchronous);
2537
try
2638
{
27-
pipeClient.Connect(connectionTimeoutMS);
39+
_pipeClient.Connect(connectionTimeoutMS);
2840
log("Connected.");
2941
}
3042
catch (TimeoutException)
3143
{
3244
log($"Failed to connect in {connectionTimeoutMS}ms.");
33-
pipeClient.Dispose();
45+
_pipeClient.Dispose();
3446
return Task.CompletedTask;
3547
}
3648

3749
try
3850
{
3951
// block execution of the app until initial updates are applied:
40-
InitializeAsync(pipeClient, cancellationToken).GetAwaiter().GetResult();
52+
InitializeAsync(cancellationToken).GetAwaiter().GetResult();
4153
}
4254
catch (Exception e)
4355
{
@@ -46,7 +58,7 @@ public Task Listen(CancellationToken cancellationToken)
4658
log(e.Message);
4759
}
4860

49-
pipeClient.Dispose();
61+
_pipeClient.Dispose();
5062
agent.Dispose();
5163

5264
return Task.CompletedTask;
@@ -56,48 +68,52 @@ public Task Listen(CancellationToken cancellationToken)
5668
{
5769
try
5870
{
59-
await ReceiveAndApplyUpdatesAsync(pipeClient, initialUpdates: false, cancellationToken);
71+
await ReceiveAndApplyUpdatesAsync(initialUpdates: false, cancellationToken);
6072
}
6173
catch (Exception e) when (e is not OperationCanceledException)
6274
{
6375
log(e.Message);
6476
}
6577
finally
6678
{
67-
pipeClient.Dispose();
79+
_pipeClient.Dispose();
6880
agent.Dispose();
6981
}
7082
}, cancellationToken);
7183
}
7284

73-
private async Task InitializeAsync(NamedPipeClientStream pipeClient, CancellationToken cancellationToken)
85+
private async Task InitializeAsync(CancellationToken cancellationToken)
7486
{
87+
Debug.Assert(_pipeClient != null);
88+
7589
agent.Reporter.Report("Writing capabilities: " + agent.Capabilities, AgentMessageSeverity.Verbose);
7690

7791
var initPayload = new ClientInitializationResponse(agent.Capabilities);
78-
await initPayload.WriteAsync(pipeClient, cancellationToken);
92+
await initPayload.WriteAsync(_pipeClient, cancellationToken);
7993

8094
// Apply updates made before this process was launched to avoid executing unupdated versions of the affected modules.
8195

8296
// We should only receive ManagedCodeUpdate when when the debugger isn't attached,
8397
// otherwise the initialization should send InitialUpdatesCompleted immediately.
8498
// The debugger itself applies these updates when launching process with the debugger attached.
85-
await ReceiveAndApplyUpdatesAsync(pipeClient, initialUpdates: true, cancellationToken);
99+
await ReceiveAndApplyUpdatesAsync(initialUpdates: true, cancellationToken);
86100
}
87101

88-
private async Task ReceiveAndApplyUpdatesAsync(NamedPipeClientStream pipeClient, bool initialUpdates, CancellationToken cancellationToken)
102+
private async Task ReceiveAndApplyUpdatesAsync(bool initialUpdates, CancellationToken cancellationToken)
89103
{
90-
while (pipeClient.IsConnected)
104+
Debug.Assert(_pipeClient != null);
105+
106+
while (_pipeClient.IsConnected)
91107
{
92-
var payloadType = (RequestType)await pipeClient.ReadByteAsync(cancellationToken);
108+
var payloadType = (RequestType)await _pipeClient.ReadByteAsync(cancellationToken);
93109
switch (payloadType)
94110
{
95111
case RequestType.ManagedCodeUpdate:
96-
await ReadAndApplyManagedCodeUpdateAsync(pipeClient, cancellationToken);
112+
await ReadAndApplyManagedCodeUpdateAsync(cancellationToken);
97113
break;
98114

99115
case RequestType.StaticAssetUpdate:
100-
await ReadAndApplyStaticAssetUpdateAsync(pipeClient, cancellationToken);
116+
await ReadAndApplyStaticAssetUpdateAsync(cancellationToken);
101117
break;
102118

103119
case RequestType.InitialUpdatesCompleted when initialUpdates:
@@ -110,11 +126,11 @@ private async Task ReceiveAndApplyUpdatesAsync(NamedPipeClientStream pipeClient,
110126
}
111127
}
112128

113-
private async ValueTask ReadAndApplyManagedCodeUpdateAsync(
114-
NamedPipeClientStream pipeClient,
115-
CancellationToken cancellationToken)
129+
private async ValueTask ReadAndApplyManagedCodeUpdateAsync(CancellationToken cancellationToken)
116130
{
117-
var request = await ManagedCodeUpdateRequest.ReadAsync(pipeClient, cancellationToken);
131+
Debug.Assert(_pipeClient != null);
132+
133+
var request = await ManagedCodeUpdateRequest.ReadAsync(_pipeClient, cancellationToken);
118134

119135
bool success;
120136
try
@@ -131,15 +147,14 @@ private async ValueTask ReadAndApplyManagedCodeUpdateAsync(
131147

132148
var logEntries = agent.Reporter.GetAndClearLogEntries(request.ResponseLoggingLevel);
133149

134-
var response = new UpdateResponse(logEntries, success);
135-
await response.WriteAsync(pipeClient, cancellationToken);
150+
await SendResponseAsync(new UpdateResponse(logEntries, success), cancellationToken);
136151
}
137152

138-
private async ValueTask ReadAndApplyStaticAssetUpdateAsync(
139-
NamedPipeClientStream pipeClient,
140-
CancellationToken cancellationToken)
153+
private async ValueTask ReadAndApplyStaticAssetUpdateAsync(CancellationToken cancellationToken)
141154
{
142-
var request = await StaticAssetUpdateRequest.ReadAsync(pipeClient, cancellationToken);
155+
Debug.Assert(_pipeClient != null);
156+
157+
var request = await StaticAssetUpdateRequest.ReadAsync(_pipeClient, cancellationToken);
143158

144159
try
145160
{
@@ -155,8 +170,22 @@ private async ValueTask ReadAndApplyStaticAssetUpdateAsync(
155170
// Updating static asset only invokes ContentUpdate metadata update handlers.
156171
// Failures of these handlers are reported to the log and ignored.
157172
// Therefore, this request always succeeds.
158-
var response = new UpdateResponse(logEntries, success: true);
173+
await SendResponseAsync(new UpdateResponse(logEntries, success: true), cancellationToken);
174+
}
159175

160-
await response.WriteAsync(pipeClient, cancellationToken);
176+
internal async ValueTask SendResponseAsync<T>(T response, CancellationToken cancellationToken)
177+
where T : IResponse
178+
{
179+
Debug.Assert(_pipeClient != null);
180+
try
181+
{
182+
await _messageToClientLock.WaitAsync(cancellationToken);
183+
await _pipeClient.WriteAsync((byte)response.Type, cancellationToken);
184+
await response.WriteAsync(_pipeClient, cancellationToken);
185+
}
186+
finally
187+
{
188+
_messageToClientLock.Release();
189+
}
161190
}
162191
}

src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,49 @@ public static void Initialize()
4040

4141
RegisterSignalHandlers();
4242

43-
var agent = new HotReloadAgent(assemblyResolvingHandler: (_, args) =>
44-
{
45-
Log($"Resolving '{args.Name}, Version={args.Version}'");
46-
var path = Path.Combine(processDir, args.Name + ".dll");
47-
return File.Exists(path) ? AssemblyLoadContext.Default.LoadFromAssemblyPath(path) : null;
48-
});
43+
PipeListener? listener = null;
44+
45+
var agent = new HotReloadAgent(
46+
assemblyResolvingHandler: (_, args) =>
47+
{
48+
Log($"Resolving '{args.Name}, Version={args.Version}'");
49+
var path = Path.Combine(processDir, args.Name + ".dll");
50+
return File.Exists(path) ? AssemblyLoadContext.Default.LoadFromAssemblyPath(path) : null;
51+
},
52+
hotReloadExceptionCreateHandler: (code, message) =>
53+
{
54+
// Continue executing the code if the debugger is attached.
55+
// It will throw the exception and the debugger will handle it.
56+
if (Debugger.IsAttached)
57+
{
58+
return;
59+
}
60+
61+
Debug.Assert(listener != null);
62+
Log($"Runtime rude edit detected: '{message}'");
63+
64+
SendAndForgetAsync().Wait();
65+
66+
// Handle Ctrl+C to terminate gracefully:
67+
Console.CancelKeyPress += (_, _) => Environment.Exit(0);
68+
69+
// wait for the process to be terminated by the Hot Reload client (other threads might still execute):
70+
Thread.Sleep(Timeout.Infinite);
71+
72+
async Task SendAndForgetAsync()
73+
{
74+
try
75+
{
76+
await listener.SendResponseAsync(new HotReloadExceptionCreatedNotification(code, message), CancellationToken.None);
77+
}
78+
catch
79+
{
80+
// do not crash the app
81+
}
82+
}
83+
});
4984

50-
var listener = new PipeListener(s_namedPipeName, agent, Log);
85+
listener = new PipeListener(s_namedPipeName, agent, Log);
5186

5287
// fire and forget:
5388
_ = listener.Listen(CancellationToken.None);

src/BuiltInTools/HotReloadAgent.PipeRpc/NamedPipeContract.cs

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,39 @@
1212

1313
namespace Microsoft.DotNet.HotReload;
1414

15-
internal interface IRequest
15+
internal interface IMessage
1616
{
17-
RequestType Type { get; }
1817
ValueTask WriteAsync(Stream stream, CancellationToken cancellationToken);
1918
}
2019

20+
internal interface IRequest : IMessage
21+
{
22+
RequestType Type { get; }
23+
}
24+
25+
internal interface IResponse : IMessage
26+
{
27+
ResponseType Type { get; }
28+
}
29+
2130
internal interface IUpdateRequest : IRequest
2231
{
2332
}
2433

25-
internal enum RequestType
34+
internal enum RequestType : byte
2635
{
2736
ManagedCodeUpdate = 1,
2837
StaticAssetUpdate = 2,
2938
InitialUpdatesCompleted = 3,
3039
}
3140

41+
internal enum ResponseType : byte
42+
{
43+
InitializationResponse = 1,
44+
UpdateResponse = 2,
45+
HotReloadExceptionNotification = 3,
46+
}
47+
3248
internal readonly struct ManagedCodeUpdateRequest(IReadOnlyList<RuntimeManagedCodeUpdate> updates, ResponseLoggingLevel responseLoggingLevel) : IUpdateRequest
3349
{
3450
private const byte Version = 4;
@@ -81,8 +97,10 @@ public static async ValueTask<ManagedCodeUpdateRequest> ReadAsync(Stream stream,
8197
}
8298
}
8399

84-
internal readonly struct UpdateResponse(IReadOnlyCollection<(string message, AgentMessageSeverity severity)> log, bool success)
100+
internal readonly struct UpdateResponse(IReadOnlyCollection<(string message, AgentMessageSeverity severity)> log, bool success) : IResponse
85101
{
102+
public ResponseType Type => ResponseType.UpdateResponse;
103+
86104
public async ValueTask WriteAsync(Stream stream, CancellationToken cancellationToken)
87105
{
88106
await stream.WriteAsync(success, cancellationToken);
@@ -116,10 +134,12 @@ public async ValueTask WriteAsync(Stream stream, CancellationToken cancellationT
116134
}
117135
}
118136

119-
internal readonly struct ClientInitializationResponse(string capabilities)
137+
internal readonly struct ClientInitializationResponse(string capabilities) : IResponse
120138
{
121139
private const byte Version = 0;
122140

141+
public ResponseType Type => ResponseType.InitializationResponse;
142+
123143
public string Capabilities { get; } = capabilities;
124144

125145
public async ValueTask WriteAsync(Stream stream, CancellationToken cancellationToken)
@@ -141,6 +161,26 @@ public static async ValueTask<ClientInitializationResponse> ReadAsync(Stream str
141161
}
142162
}
143163

164+
internal readonly struct HotReloadExceptionCreatedNotification(int code, string message) : IResponse
165+
{
166+
public ResponseType Type => ResponseType.HotReloadExceptionNotification;
167+
public int Code => code;
168+
public string Message => message;
169+
170+
public async ValueTask WriteAsync(Stream stream, CancellationToken cancellationToken)
171+
{
172+
await stream.WriteAsync(code, cancellationToken);
173+
await stream.WriteAsync(message, cancellationToken);
174+
}
175+
176+
public static async ValueTask<HotReloadExceptionCreatedNotification> ReadAsync(Stream stream, CancellationToken cancellationToken)
177+
{
178+
var code = await stream.ReadInt32Async(cancellationToken);
179+
var message = await stream.ReadStringAsync(cancellationToken);
180+
return new HotReloadExceptionCreatedNotification(code, message);
181+
}
182+
}
183+
144184
internal readonly struct StaticAssetUpdateRequest(
145185
RuntimeStaticAssetUpdate update,
146186
ResponseLoggingLevel responseLoggingLevel) : IUpdateRequest

src/BuiltInTools/HotReloadAgent.WebAssembly.Browser/WebAssemblyHotReload.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ public static async Task InitializeAsync(string baseUri)
7171
{
7272
s_initialized = true;
7373

74-
var agent = new HotReloadAgent(assemblyResolvingHandler: null);
74+
// TODO: Implement hotReloadExceptionCreateHandler: https://github.com/dotnet/sdk/issues/51056
75+
var agent = new HotReloadAgent(assemblyResolvingHandler: null, hotReloadExceptionCreateHandler: null);
7576

7677
var existingAgent = Interlocked.CompareExchange(ref s_hotReloadAgent, agent, null);
7778
if (existingAgent != null)

0 commit comments

Comments
 (0)