Skip to content

Commit f812e2f

Browse files
committed
Implement posix signal registration
1 parent 03423f4 commit f812e2f

12 files changed

+122
-66
lines changed

src/BuiltInTools/DotNetDeltaApplier/Microsoft.Extensions.DotNetDeltaApplier.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
dotnet-watch may inject this assembly to .NET 6.0+ app, so we can't target a newer version.
88
At the same time source build requires us to not target 6.0, so we fall back to netstandard.
99
-->
10-
<TargetFramework>netstandard2.1</TargetFramework>
10+
<TargetFrameworks>netstandard2.1;net10.0</TargetFrameworks>
1111
<StrongNameKeyId>MicrosoftAspNetCore</StrongNameKeyId>
1212

1313
<!-- NuGet -->

src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Diagnostics;
55
using System.IO.Pipes;
6+
using System.Reflection;
67
using Microsoft.DotNet.HotReload;
78

89
/// <summary>
@@ -13,7 +14,7 @@ internal sealed class StartupHook
1314
private const int ConnectionTimeoutMS = 5000;
1415

1516
private static readonly bool s_logToStandardOutput = Environment.GetEnvironmentVariable(AgentEnvironmentVariables.HotReloadDeltaClientLogMessages) == "1";
16-
private static readonly string s_namedPipeName = Environment.GetEnvironmentVariable(AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName);
17+
private static readonly string? s_namedPipeName = Environment.GetEnvironmentVariable(AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName);
1718

1819
/// <summary>
1920
/// Invoked by the runtime when the containing assembly is listed in DOTNET_STARTUP_HOOKS.
@@ -28,6 +29,12 @@ public static void Initialize()
2829

2930
Log($"Connecting to hot-reload server");
3031

32+
if (s_namedPipeName == null)
33+
{
34+
Log($"Environment variable {AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName} has no value");
35+
return;
36+
}
37+
3138
// Connect to the pipe synchronously.
3239
//
3340
// If a debugger is attached and there is a breakpoint in the startup code connecting asynchronously would
@@ -48,22 +55,46 @@ public static void Initialize()
4855
return;
4956
}
5057

58+
RegisterPosixSignalHandlers();
59+
5160
var agent = new HotReloadAgent();
5261
try
5362
{
5463
// block until initialization completes:
5564
InitializeAsync(pipeClient, agent, CancellationToken.None).GetAwaiter().GetResult();
5665

66+
#pragma warning disable CA2025 // Ensure tasks using 'IDisposable' instances complete before the instances are disposed
5767
// fire and forget:
5868
_ = ReceiveAndApplyUpdatesAsync(pipeClient, agent, initialUpdates: false, CancellationToken.None);
69+
#pragma warning restore
5970
}
6071
catch (Exception ex)
6172
{
6273
Log(ex.Message);
6374
pipeClient.Dispose();
75+
agent.Dispose();
6476
}
6577
}
6678

79+
private static void RegisterPosixSignalHandlers()
80+
{
81+
#if NET10_0_OR_GREATER
82+
// Register a handler for SIGTERM to allow graceful shutdown of the application on Unix.
83+
// See https://github.com/dotnet/docs/issues/46226.
84+
85+
// Note: registered handlers are executed in reverse order of their registration.
86+
// Since the startup hook is executed before any code of the application, it is the first handler registered and thus the last to run.
87+
88+
_ = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context =>
89+
{
90+
if (!context.Cancel)
91+
Environment.Exit(0);
92+
});
93+
94+
Log("Posix signal handlers registered.");
95+
#endif
96+
}
97+
6798
private static async ValueTask InitializeAsync(NamedPipeClientStream pipeClient, HotReloadAgent agent, CancellationToken cancellationToken)
6899
{
69100
agent.Reporter.Report("Writing capabilities: " + agent.Capabilities, AgentMessageSeverity.Verbose);

src/BuiltInTools/dotnet-watch/DotNetWatcher.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke
6262
};
6363

6464
var browserRefreshServer = (projectRootNode != null)
65-
? await browserConnector.GetOrCreateBrowserRefreshServerAsync(projectRootNode, processSpec, environmentBuilder, Context.RootProjectOptions, DefaultAppModel.Instance, shutdownCancellationToken)
65+
? await browserConnector.GetOrCreateBrowserRefreshServerAsync(projectRootNode, processSpec, environmentBuilder, Context.RootProjectOptions, new DefaultAppModel(projectRootNode), shutdownCancellationToken)
6666
: null;
6767

6868
environmentBuilder.SetProcessEnvironmentVariables(processSpec);

src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyAppModel.cs

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,30 +5,23 @@
55

66
namespace Microsoft.DotNet.Watch;
77

8-
internal abstract partial class HotReloadAppModel
8+
/// <summary>
9+
/// Blazor client-only WebAssembly app.
10+
/// </summary>
11+
internal sealed class BlazorWebAssemblyAppModel(ProjectGraphNode clientProject)
12+
// Blazor WASM does not need agent injected as all changes are applied in the browser, the process being launched is a dev server.
13+
: HotReloadAppModel(agentInjectionProject: null)
914
{
10-
/// <summary>
11-
/// Blazor client-only WebAssembly app.
12-
/// </summary>
13-
internal sealed class BlazorWebAssemblyAppModel(ProjectGraphNode clientProject) : HotReloadAppModel
14-
{
15-
public override bool RequiresBrowserRefresh => true;
16-
17-
/// <summary>
18-
/// Blazor WASM does not need dotnet applier as all changes are applied in the browser,
19-
/// the process being launched is a dev server.
20-
/// </summary>
21-
public override bool InjectDeltaApplier => false;
15+
public override bool RequiresBrowserRefresh => true;
2216

23-
public override DeltaApplier? CreateDeltaApplier(BrowserRefreshServer? browserRefreshServer, IReporter processReporter)
17+
public override DeltaApplier? CreateDeltaApplier(BrowserRefreshServer? browserRefreshServer, IReporter processReporter)
18+
{
19+
if (browserRefreshServer == null)
2420
{
25-
if (browserRefreshServer == null)
26-
{
27-
// error has been reported earlier
28-
return null;
29-
}
30-
31-
return new BlazorWebAssemblyDeltaApplier(processReporter, browserRefreshServer, clientProject);
21+
// error has been reported earlier
22+
return null;
3223
}
24+
25+
return new BlazorWebAssemblyDeltaApplier(processReporter, browserRefreshServer, clientProject);
3326
}
3427
}

src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyHostedAppModel.cs

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,24 @@
55

66
namespace Microsoft.DotNet.Watch;
77

8-
internal abstract partial class HotReloadAppModel
8+
/// <summary>
9+
/// Blazor WebAssembly app hosted by an ASP.NET Core app.
10+
/// App has a client and server projects and deltas are applied to both processes.
11+
/// Agent is injected into the server process. The client process is updated via WebSocketScriptInjection.js injected into the browser.
12+
/// </summary>
13+
internal sealed class BlazorWebAssemblyHostedAppModel(ProjectGraphNode clientProject, ProjectGraphNode serverProject)
14+
: HotReloadAppModel(agentInjectionProject: serverProject)
915
{
10-
/// <summary>
11-
/// Blazor WebAssembly app hosted by an ASP.NET Core app.
12-
/// App has a client and server projects and deltas are applied to both processes.
13-
/// </summary>
14-
internal sealed class BlazorWebAssemblyHostedAppModel(ProjectGraphNode clientProject) : HotReloadAppModel
15-
{
16-
public override bool RequiresBrowserRefresh => true;
17-
public override bool InjectDeltaApplier => true;
16+
public override bool RequiresBrowserRefresh => true;
1817

19-
public override DeltaApplier? CreateDeltaApplier(BrowserRefreshServer? browserRefreshServer, IReporter processReporter)
18+
public override DeltaApplier? CreateDeltaApplier(BrowserRefreshServer? browserRefreshServer, IReporter processReporter)
19+
{
20+
if (browserRefreshServer == null)
2021
{
21-
if (browserRefreshServer == null)
22-
{
23-
// error has been reported earlier
24-
return null;
25-
}
26-
27-
return new BlazorWebAssemblyHostedDeltaApplier(processReporter, browserRefreshServer, clientProject);
22+
// error has been reported earlier
23+
return null;
2824
}
25+
26+
return new BlazorWebAssemblyHostedDeltaApplier(processReporter, browserRefreshServer, clientProject);
2927
}
3028
}

src/BuiltInTools/dotnet-watch/HotReload/DefaultAppModel.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
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 Microsoft.Build.Graph;
5+
46
namespace Microsoft.DotNet.Watch;
57

68
/// <summary>
79
/// Default model.
810
/// </summary>
9-
internal sealed class DefaultAppModel : HotReloadAppModel
11+
internal sealed class DefaultAppModel(ProjectGraphNode project)
12+
: HotReloadAppModel(agentInjectionProject: project)
1013
{
11-
public static readonly DefaultAppModel Instance = new();
12-
1314
public override bool RequiresBrowserRefresh => false;
14-
public override bool InjectDeltaApplier => true;
1515

1616
public override DeltaApplier? CreateDeltaApplier(BrowserRefreshServer? browserRefreshServer, IReporter processReporter)
1717
=> new DefaultDeltaApplier(processReporter);

src/BuiltInTools/dotnet-watch/HotReload/DeltaApplier.cs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ internal abstract class DeltaApplier(IReporter reporter) : IDisposable
1212
{
1313
public readonly IReporter Reporter = reporter;
1414

15-
public static readonly string StartupHookPath = Path.Combine(AppContext.BaseDirectory, "hotreload", "Microsoft.Extensions.DotNetDeltaApplier.dll");
16-
1715
public abstract void CreateConnection(string namedPipeName, CancellationToken cancellationToken);
1816

1917
/// <summary>

src/BuiltInTools/dotnet-watch/HotReload/HotReloadAppModel.cs

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,38 @@
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 Microsoft.Build.Execution;
4+
using System.Diagnostics.CodeAnalysis;
55
using Microsoft.Build.Graph;
66

77
namespace Microsoft.DotNet.Watch;
88

9-
internal abstract partial class HotReloadAppModel
9+
internal abstract partial class HotReloadAppModel(ProjectGraphNode? agentInjectionProject)
1010
{
1111
public abstract bool RequiresBrowserRefresh { get; }
1212

13+
public abstract DeltaApplier? CreateDeltaApplier(BrowserRefreshServer? browserRefreshServer, IReporter processReporter);
14+
1315
/// <summary>
14-
/// True to inject delta applier to the process.
16+
/// Returns true and the path to the client agent implementation binary if the application needs the agent to be injected.
1517
/// </summary>
16-
public abstract bool InjectDeltaApplier { get; }
18+
public bool TryGetStartupHookPath([NotNullWhen(true)] out string? path)
19+
{
20+
if (agentInjectionProject == null)
21+
{
22+
path = null;
23+
return false;
24+
}
1725

18-
public abstract DeltaApplier? CreateDeltaApplier(BrowserRefreshServer? browserRefreshServer, IReporter processReporter);
26+
var hookTargetFramework = agentInjectionProject.GetTargetFramework() switch
27+
{
28+
// Note: Hot Reload is only supported on net6.0+
29+
"net6.0" or "net7.0" or "net8.0" or "net9.0" => "netstandard2.1",
30+
_ => "net10.0",
31+
};
32+
33+
path = Path.Combine(AppContext.BaseDirectory, "hotreload", hookTargetFramework, "Microsoft.Extensions.DotNetDeltaApplier.dll");
34+
return true;
35+
}
1936

2037
public static HotReloadAppModel InferFromProject(ProjectGraphNode projectNode, IReporter reporter)
2138
{
@@ -24,7 +41,7 @@ public static HotReloadAppModel InferFromProject(ProjectGraphNode projectNode, I
2441
var queue = new Queue<ProjectGraphNode>();
2542
queue.Enqueue(projectNode);
2643

27-
ProjectInstance? aspnetCoreProject = null;
44+
ProjectGraphNode? aspnetCoreProject = null;
2845

2946
var visited = new HashSet<ProjectGraphNode>();
3047

@@ -37,17 +54,17 @@ public static HotReloadAppModel InferFromProject(ProjectGraphNode projectNode, I
3754
{
3855
if (item.EvaluatedInclude == "AspNetCore")
3956
{
40-
aspnetCoreProject = currentNode.ProjectInstance;
57+
aspnetCoreProject = currentNode;
4158
break;
4259
}
4360

4461
if (item.EvaluatedInclude == "WebAssembly")
4562
{
4663
// We saw a previous project that was AspNetCore. This must be a blazor hosted app.
47-
if (aspnetCoreProject is not null && aspnetCoreProject != currentNode.ProjectInstance)
64+
if (aspnetCoreProject is not null && aspnetCoreProject.ProjectInstance != currentNode.ProjectInstance)
4865
{
49-
reporter.Verbose($"HotReloadProfile: BlazorHosted. {aspnetCoreProject.FullPath} references BlazorWebAssembly project {currentNode.ProjectInstance.FullPath}.", emoji: "🔥");
50-
return new BlazorWebAssemblyHostedAppModel(clientProject: currentNode);
66+
reporter.Verbose($"HotReloadProfile: BlazorHosted. {aspnetCoreProject.ProjectInstance.FullPath} references BlazorWebAssembly project {currentNode.ProjectInstance.FullPath}.", emoji: "🔥");
67+
return new BlazorWebAssemblyHostedAppModel(clientProject: currentNode, serverProject: aspnetCoreProject);
5168
}
5269

5370
reporter.Verbose("HotReloadProfile: BlazorWebAssembly.", emoji: "🔥");
@@ -66,6 +83,6 @@ public static HotReloadAppModel InferFromProject(ProjectGraphNode projectNode, I
6683
}
6784

6885
reporter.Verbose("HotReloadProfile: Default.", emoji: "🔥");
69-
return DefaultAppModel.Instance;
86+
return new DefaultAppModel(projectNode);
7087
}
7188
}

src/BuiltInTools/dotnet-watch/HotReload/ProjectLauncher.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,10 @@ public EnvironmentOptions EnvironmentOptions
7777
// https://github.com/dotnet/runtime/blob/342936c5a88653f0f622e9d6cb727a0e59279b31/src/mono/browser/runtime/loader/config.ts#L330
7878
environmentBuilder.SetVariable(EnvironmentVariables.Names.DotNetModifiableAssemblies, "debug");
7979

80-
if (appModel.InjectDeltaApplier)
80+
if (appModel.TryGetStartupHookPath(out var startupHookPath))
8181
{
8282
// HotReload startup hook should be loaded before any other startup hooks:
83-
environmentBuilder.DotNetStartupHooks.Insert(0, DeltaApplier.StartupHookPath);
83+
environmentBuilder.DotNetStartupHooks.Insert(0, startupHookPath);
8484

8585
environmentBuilder.SetVariable(EnvironmentVariables.Names.DotNetWatchHotReloadNamedPipeName, namedPipeName);
8686

src/BuiltInTools/dotnet-watch/dotnet-watch.csproj

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22

33
<Import Project="..\AspireService\Microsoft.WebTools.AspireService.projitems" Label="Shared" />
44
<Import Project="..\HotReloadAgent.PipeRpc\Microsoft.DotNet.HotReload.Agent.PipeRpc.projitems" Label="Shared" />
@@ -50,8 +50,27 @@
5050
<ItemGroup>
5151
<ProjectReference Include="$(RepoRoot)\src\Cli\dotnet\dotnet.csproj" />
5252
<ProjectReference Include="..\BrowserRefresh\Microsoft.AspNetCore.Watch.BrowserRefresh.csproj" PrivateAssets="All" ReferenceOutputAssembly="false" SkipGetTargetFrameworkProperties="true" UndefineProperties="TargetFramework;TargetFrameworks" OutputItemType="Content" TargetPath="middleware\Microsoft.AspNetCore.Watch.BrowserRefresh.dll" CopyToOutputDirectory="PreserveNewest" />
53-
<ProjectReference Include="..\DotNetDeltaApplier\Microsoft.Extensions.DotNetDeltaApplier.csproj" PrivateAssets="All" ReferenceOutputAssembly="false" SkipGetTargetFrameworkProperties="true" UndefineProperties="TargetFramework;TargetFrameworks" OutputItemType="Content" TargetPath="hotreload\Microsoft.Extensions.DotNetDeltaApplier.dll" CopyToOutputDirectory="PreserveNewest" />
5453
<ProjectReference Include="..\DotNetWatchTasks\DotNetWatchTasks.csproj" PrivateAssets="All" ReferenceOutputAssembly="false" SkipGetTargetFrameworkProperties="true" UndefineProperties="TargetFramework;TargetFrameworks" OutputItemType="Content" CopyToOutputDirectory="PreserveNewest" />
54+
55+
<ProjectReference Include="..\DotNetDeltaApplier\Microsoft.Extensions.DotNetDeltaApplier.csproj">
56+
<PrivateAssets>all</PrivateAssets>
57+
<OutputItemType>Content</OutputItemType>
58+
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
59+
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
60+
<SetTargetFramework>TargetFramework=net10.0</SetTargetFramework>
61+
<TargetPath>hotreload\net10.0\Microsoft.Extensions.DotNetDeltaApplier.dll</TargetPath>
62+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
63+
</ProjectReference>
64+
65+
<ProjectReference Include="..\DotNetDeltaApplier\Microsoft.Extensions.DotNetDeltaApplier.csproj">
66+
<PrivateAssets>all</PrivateAssets>
67+
<OutputItemType>Content</OutputItemType>
68+
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
69+
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
70+
<SetTargetFramework>TargetFramework=netstandard2.1</SetTargetFramework>
71+
<TargetPath>hotreload\netstandard2.1\Microsoft.Extensions.DotNetDeltaApplier.dll</TargetPath>
72+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
73+
</ProjectReference>
5574
</ItemGroup>
5675

5776
<!-- Publish dotnet-watch files to the redist testhost folder so that in innerloop, redist doesn't need to be built again. -->

test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ public async Task AutoRestartOnRudeEdit(bool nonInteractive)
105105
App.AssertOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Launched");
106106
}
107107

108-
[Fact(Skip = "https://github.com/dotnet/sdk/issues/49307")]
108+
[Fact]
109109
public async Task AutoRestartOnRudeEditAfterRestartPrompt()
110110
{
111111
var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp")
@@ -502,7 +502,7 @@ public async Task BlazorWasm_MSBuildWarning()
502502
await App.AssertWaitingForChanges();
503503
}
504504

505-
[Fact(Skip = "https://github.com/dotnet/sdk/issues/49307")]
505+
[Fact]
506506
public async Task BlazorWasm_Restart()
507507
{
508508
var testAsset = TestAssets.CopyTestAsset("WatchBlazorWasm")

test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@ public enum UpdateLocation
394394
TopFunction,
395395
}
396396

397-
[Theory(Skip = "https://github.com/dotnet/sdk/issues/49307")]
397+
[Theory]
398398
[CombinatorialData]
399399
public async Task HostRestart(UpdateLocation updateLocation)
400400
{
@@ -540,7 +540,7 @@ public enum DirectoryKind
540540
Obj,
541541
}
542542

543-
[Theory(Skip = "https://github.com/dotnet/sdk/issues/49307")]
543+
[Theory]
544544
[CombinatorialData]
545545
public async Task IgnoredChange(bool isExisting, bool isIncluded, DirectoryKind directoryKind)
546546
{

0 commit comments

Comments
 (0)