Skip to content

Commit

Permalink
Handle SIGTERM in Hosting and handle just like SIGINT (CTRL+C)
Browse files Browse the repository at this point in the history
Don't listen to ProcessExit on net6.0+ in Hosting anymore. This allows for Environment.Exit to not hang the app.
Don't clobber ExitCode during ProcessExit now that SIGTERM is handled separately.

For non-net6.0 targets, only wait for the shutdown timeout, so the process doesn't hang forever.

Fix dotnet#55417
Fix dotnet#44086
Fix dotnet#50397
Fix dotnet#42224
Fix dotnet#35990
  • Loading branch information
eerhardt committed Jul 20, 2021
1 parent dd5f734 commit 280e63e
Show file tree
Hide file tree
Showing 5 changed files with 249 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ namespace Microsoft.Extensions.Hosting.Internal
[UnsupportedOSPlatform("ios")]
[UnsupportedOSPlatform("maccatalyst")]
[UnsupportedOSPlatform("tvos")]
public class ConsoleLifetime : IHostLifetime, IDisposable
public partial class ConsoleLifetime : IHostLifetime, IDisposable
{
private readonly ManualResetEvent _shutdownBlock = new ManualResetEvent(false);
private CancellationTokenRegistration _applicationStartedRegistration;
Expand Down Expand Up @@ -63,13 +63,15 @@ public Task WaitForStartAsync(CancellationToken cancellationToken)
this);
}

AppDomain.CurrentDomain.ProcessExit += OnProcessExit;
Console.CancelKeyPress += OnCancelKeyPress;
RegisterShutdownHandlers();

// Console applications start immediately.
return Task.CompletedTask;
}

private partial void RegisterShutdownHandlers();

private void OnApplicationStarted()
{
Logger.LogInformation("Application started. Press Ctrl+C to shut down.");
Expand All @@ -82,25 +84,17 @@ private void OnApplicationStopping()
Logger.LogInformation("Application is shutting down...");
}

private void OnProcessExit(object sender, EventArgs e)
private void OnCancelKeyPress(object sender, ConsoleCancelEventArgs e)
{
ApplicationLifetime.StopApplication();
if (!_shutdownBlock.WaitOne(HostOptions.ShutdownTimeout))
{
Logger.LogInformation("Waiting for the host to be disposed. Ensure all 'IHost' instances are wrapped in 'using' blocks.");
}
_shutdownBlock.WaitOne();
// On Linux if the shutdown is triggered by SIGTERM then that's signaled with the 143 exit code.
// Suppress that since we shut down gracefully. https://github.com/dotnet/aspnetcore/issues/6526
System.Environment.ExitCode = 0;
e.Cancel = true;
OnExitSignal();
}

private void OnCancelKeyPress(object sender, ConsoleCancelEventArgs e)
private void OnExitSignal()
{
e.Cancel = true;
ApplicationLifetime.StopApplication();

// Don't block in process shutdown for CTRL+C/SIGINT since we can set e.Cancel to true
// Don't block in process shutdown for CTRL+C/SIGINT/SIGTERM since we can set e.Cancel to true
// we assume that application code will unwind once StopApplication signals the token
_shutdownBlock.Set();
}
Expand All @@ -115,11 +109,13 @@ public void Dispose()
{
_shutdownBlock.Set();

AppDomain.CurrentDomain.ProcessExit -= OnProcessExit;
UnregisterShutdownHandlers();
Console.CancelKeyPress -= OnCancelKeyPress;

_applicationStartedRegistration.Dispose();
_applicationStoppingRegistration.Dispose();
}

private partial void UnregisterShutdownHandlers();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// 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.Runtime.InteropServices;

namespace Microsoft.Extensions.Hosting.Internal
{
public partial class ConsoleLifetime : IHostLifetime
{
private PosixSignalRegistration _sigTermRegistration;

private partial void RegisterShutdownHandlers()
{
_sigTermRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => OnSigTerm(context));
}

private void OnSigTerm(PosixSignalContext context)
{
Debug.Assert(context.Signal == PosixSignal.SIGTERM);

context.Cancel = true;
OnExitSignal();
}

private partial void UnregisterShutdownHandlers()
{
_sigTermRegistration?.Dispose();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using Microsoft.Extensions.Logging;

namespace Microsoft.Extensions.Hosting.Internal
{
public partial class ConsoleLifetime : IHostLifetime
{
private partial void RegisterShutdownHandlers()
{
AppDomain.CurrentDomain.ProcessExit += OnProcessExit;
}

private void OnProcessExit(object sender, EventArgs e)
{
ApplicationLifetime.StopApplication();

if (!_shutdownBlock.WaitOne(HostOptions.ShutdownTimeout))
{
Logger.LogInformation("Waiting for the host to be disposed. Ensure all 'IHost' instances are wrapped in 'using' blocks.");
}

// wait one more time after the above error message, but only for ShutdownTimeout, so it doesn't hang forever
_shutdownBlock.WaitOne(HostOptions.ShutdownTimeout);

// On Linux if the shutdown is triggered by SIGTERM then that's signaled with the 143 exit code.
// Suppress that since we shut down gracefully. https://github.com/dotnet/aspnetcore/issues/6526

// This only applies to non-net6.0+, since in net6.0 we added a handler specific for SIGTERM,
// so we don't need to reset the ExitCode in ProcessExit anymore.
System.Environment.ExitCode = 0;
}

private partial void UnregisterShutdownHandlers()
{
AppDomain.CurrentDomain.ProcessExit -= OnProcessExit;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,20 @@
<DisableImplicitAssemblyReferences>false</DisableImplicitAssemblyReferences>
</PropertyGroup>

<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net6.0'))">
<Compile Remove="Internal\ConsoleLifetime.netcoreapp.cs" />
</ItemGroup>

<ItemGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net6.0'))">
<Compile Remove="Internal\ConsoleLifetime.notnetcoreapp.cs" />
</ItemGroup>

<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net5.0'))">
<Compile Include="$(CoreLibSharedDir)System\Diagnostics\CodeAnalysis\UnconditionalSuppressMessageAttribute.cs" />
<Compile Include="$(CoreLibSharedDir)System\Diagnostics\CodeAnalysis\DynamicallyAccessedMembersAttribute.cs" />
<Compile Include="$(CoreLibSharedDir)System\Diagnostics\CodeAnalysis\DynamicallyAccessedMemberTypes.cs" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="$(LibrariesProjectRoot)Microsoft.Extensions.Configuration\src\Microsoft.Extensions.Configuration.csproj" />
<ProjectReference Include="$(LibrariesProjectRoot)Microsoft.Extensions.Configuration.Abstractions\src\Microsoft.Extensions.Configuration.Abstractions.csproj" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.DotNet.RemoteExecutor;
using Microsoft.Extensions.DependencyInjection;
using Xunit;

namespace Microsoft.Extensions.Hosting.Tests
{
public class ConsoleLifetimeExitTests
{
/// <summary>
/// Tests that a Hosted process that receives SIGTERM/SIGINT completes successfully
/// and the rest of "main" gets executed.
/// </summary>
[ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))]
[PlatformSpecific(TestPlatforms.AnyUnix)]
[InlineData(SIGTERM)]
[InlineData(SIGINT)]
public async Task EnsureSignalContinuesMainMethod(int signal)
{
using var remoteHandle = RemoteExecutor.Invoke(async () =>
{
await Host.CreateDefaultBuilder()
.ConfigureServices((hostContext, services) =>
{
services.AddHostedService<EnsureSignalContinuesMainMethodWorker>();
})
.RunConsoleAsync();
// adding this delay ensures the "main" method loses in a race with the normal process exit
// and can cause the below message not to be written when the normal process exit isn't canceled by the
// SIGTERM handler
await Task.Delay(20);
Console.WriteLine("Run has completed");
return 123;
}, new RemoteInvokeOptions() { Start = false, ExpectedExitCode = 123 });

remoteHandle.Process.StartInfo.RedirectStandardOutput = true;
remoteHandle.Process.Start();

// wait for the host process to start
string line;
while ((line = remoteHandle.Process.StandardOutput.ReadLine()).EndsWith("Started"))
{
await Task.Delay(20);
}

// send SIGTERM/SIGINT to the process
kill(remoteHandle.Process.Id, signal);

remoteHandle.Process.WaitForExit();

string processOutput = remoteHandle.Process.StandardOutput.ReadToEnd();
Assert.Contains("Run has completed", processOutput);
Assert.Equal(123, remoteHandle.Process.ExitCode);
}

private const int SIGINT = 2;
private const int SIGTERM = 15;

[DllImport("libc", SetLastError = true)]
private static extern int kill(int pid, int sig);

private class EnsureSignalContinuesMainMethodWorker : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(20, stoppingToken);
Console.WriteLine("Started");
}
catch (OperationCanceledException)
{
return;
}
}
}
}

/// <summary>
/// Tests that calling Environment.Exit from a Hosted app sets the correct exit code.
/// </summary>
[ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))]
// SIGTERM is only handled on net6.0+, so the workaround to "clobber" the exit code is still in place on NetFramework
[SkipOnTargetFramework(TargetFrameworkMonikers.NetFramework)]
public void EnsureEnvironmentExitCode()
{
using var remoteHandle = RemoteExecutor.Invoke(async () =>
{
await Host.CreateDefaultBuilder()
.ConfigureServices((hostContext, services) =>
{
services.AddHostedService<EnsureEnvironmentExitCodeWorker>();
})
.RunConsoleAsync();
});

remoteHandle.Process.WaitForExit();

Assert.Equal(124, remoteHandle.Process.ExitCode);
}

private class EnsureEnvironmentExitCodeWorker : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Run(() =>
{
Environment.Exit(124);
});
}
}

/// <summary>
/// Tests that calling Environment.Exit from the "main" thread doesn't hang the process forever.
/// </summary>
[ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))]
public void EnsureEnvironmentExitDoesntHang()
{
using var remoteHandle = RemoteExecutor.Invoke(async () =>
{
await Host.CreateDefaultBuilder()
.ConfigureHostOptions(o => o.ShutdownTimeout = TimeSpan.FromMilliseconds(100))
.ConfigureServices((hostContext, services) =>
{
services.AddHostedService<EnsureEnvironmentExitDoesntHangWorker>();
})
.RunConsoleAsync();
}, new RemoteInvokeOptions() { TimeOut = 10_000 }); // give a 10 second time out, so if this does hang, it doesn't hang for the full timeout

Assert.True(remoteHandle.Process.WaitForExit(10_000), "The hosted process should have exited within 10 seconds");

// SIGTERM is only handled on net6.0+, so the workaround to "clobber" the exit code is still in place on NetFramework
int expectedExitCode = PlatformDetection.IsNetFramework ? 0 : 125;
Assert.Equal(expectedExitCode, remoteHandle.Process.ExitCode);
}

private class EnsureEnvironmentExitDoesntHangWorker : BackgroundService
{
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
Environment.Exit(125);
return Task.CompletedTask;
}
}
}
}

0 comments on commit 280e63e

Please sign in to comment.