From 585f59cc270a89552bdb02d6a20dc61a01f7934a Mon Sep 17 00:00:00 2001 From: Anthony Simmon Date: Tue, 7 Mar 2023 23:41:11 -0500 Subject: [PATCH] Ensure MongoDB child processes are killed when current process is prematurely killed (#25) --- .github/workflows/ci.yml | 1 - .github/workflows/release.yml | 1 - README.md | 9 +- .../EphemeralMongo.Core.Tests.csproj | 7 +- .../MongoRunnerTests.cs | 11 +- src/EphemeralMongo.Core/BaseMongoProcess.cs | 5 + .../EphemeralMongo.Core.csproj | 4 + src/EphemeralMongo.Core/MongoRunnerOptions.cs | 51 ++++++++- src/EphemeralMongo.Core/NativeMethods.cs | 106 ++++++++++++++++++ src/EphemeralMongo.Core/NativeMethods.txt | 4 + src/EphemeralMongo.Core/PublicAPI.Shipped.txt | 2 + .../EphemeralMongo.runtime.targets | 1 + 12 files changed, 189 insertions(+), 13 deletions(-) create mode 100644 src/EphemeralMongo.Core/NativeMethods.cs create mode 100644 src/EphemeralMongo.Core/NativeMethods.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 702a56a..bdc2a1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,6 @@ jobs: - uses: actions/setup-dotnet@v3 with: dotnet-version: | - 3.1.x 6.0.x - uses: actions/download-artifact@v3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c9fdf9..bb02f61 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,7 +40,6 @@ jobs: - uses: actions/setup-dotnet@v3 with: dotnet-version: | - 3.1.x 6.0.x - uses: actions/download-artifact@v3 diff --git a/README.md b/README.md index 6dbb3dc..2466bee 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ This project is very much inspired from [Mongo2Go](https://github.com/Mongo2Go/M | Package | Description | Link | |---------------------|-----------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------| -| **EphemeralMongo4** | All-in-one package for **MongoDB 4.4.18** on Linux, macOS and Windows | [![nuget](https://img.shields.io/nuget/v/EphemeralMongo4.svg?logo=nuget)](https://www.nuget.org/packages/EphemeralMongo4/) | -| **EphemeralMongo5** | All-in-one package for **MongoDB 5.0.14** on Linux, macOS and Windows | [![nuget](https://img.shields.io/nuget/v/EphemeralMongo5.svg?logo=nuget)](https://www.nuget.org/packages/EphemeralMongo5/) | +| **EphemeralMongo4** | All-in-one package for **MongoDB 4.4.19** on Linux, macOS and Windows | [![nuget](https://img.shields.io/nuget/v/EphemeralMongo4.svg?logo=nuget)](https://www.nuget.org/packages/EphemeralMongo4/) | +| **EphemeralMongo5** | All-in-one package for **MongoDB 5.0.15** on Linux, macOS and Windows | [![nuget](https://img.shields.io/nuget/v/EphemeralMongo5.svg?logo=nuget)](https://www.nuget.org/packages/EphemeralMongo5/) | | **EphemeralMongo6** | All-in-one package for **MongoDB 6.0.4** on Linux, macOS and Windows | [![nuget](https://img.shields.io/nuget/v/EphemeralMongo6.svg?logo=nuget)](https://www.nuget.org/packages/EphemeralMongo6/) | @@ -47,6 +47,11 @@ var options = new MongoRunnerOptions ReplicaSetSetupTimeout = TimeSpan.FromSeconds(5), // Default: 10 seconds AdditionalArguments = "--quiet", // Default: null MongoPort = 27017, // Default: random available port + + // EXPERIMENTAL - Only works on Windows and modern .NET (netcoreapp3.1, net5.0, net6.0, net7.0 and so on): + // Ensures that all MongoDB child processes are killed when the current process is prematurely killed, + // for instance when killed from the task manager or the IDE unit tests window. + KillMongoProcessesWhenCurrentProcessExits = true // Default: false }; // Disposing the runner will kill the MongoDB process (mongod) and delete the associated data directory diff --git a/src/EphemeralMongo.Core.Tests/EphemeralMongo.Core.Tests.csproj b/src/EphemeralMongo.Core.Tests/EphemeralMongo.Core.Tests.csproj index 5dbb21a..643d0cc 100644 --- a/src/EphemeralMongo.Core.Tests/EphemeralMongo.Core.Tests.csproj +++ b/src/EphemeralMongo.Core.Tests/EphemeralMongo.Core.Tests.csproj @@ -1,20 +1,21 @@ - net462;netcoreapp3.1;net6.0 + net462;net6.0 enable enable false + - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - diff --git a/src/EphemeralMongo.Core.Tests/MongoRunnerTests.cs b/src/EphemeralMongo.Core.Tests/MongoRunnerTests.cs index f2957ad..368b747 100644 --- a/src/EphemeralMongo.Core.Tests/MongoRunnerTests.cs +++ b/src/EphemeralMongo.Core.Tests/MongoRunnerTests.cs @@ -1,6 +1,6 @@ +using GSoft.Extensions.Xunit; using Microsoft.Extensions.Logging; using MongoDB.Driver; -using ShareGate.Extensions.Xunit; using Xunit; using Xunit.Abstractions; @@ -8,8 +8,8 @@ namespace EphemeralMongo.Core.Tests; public class MongoRunnerTests : BaseIntegrationTest { - public MongoRunnerTests(ITestOutputHelper testOutputHelper) - : base(testOutputHelper) + public MongoRunnerTests(EmptyIntegrationFixture fixture, ITestOutputHelper testOutputHelper) + : base(fixture, testOutputHelper) { } @@ -21,7 +21,8 @@ public void Run_Fails_When_BinaryDirectory_Does_Not_Exist() StandardOuputLogger = x => this.Logger.LogInformation("{X}", x), StandardErrorLogger = x => this.Logger.LogInformation("{X}", x), BinaryDirectory = Guid.NewGuid().ToString(), - AdditionalArguments = string.Empty, + AdditionalArguments = "--quiet", + KillMongoProcessesWhenCurrentProcessExits = true, }; IMongoRunner? runner = null; @@ -51,6 +52,8 @@ public void Import_Export_Works(bool useSingleNodeReplicaSet) UseSingleNodeReplicaSet = useSingleNodeReplicaSet, StandardOuputLogger = x => this.Logger.LogInformation("{X}", x), StandardErrorLogger = x => this.Logger.LogInformation("{X}", x), + AdditionalArguments = "--quiet", + KillMongoProcessesWhenCurrentProcessExits = true, }; using (var runner = MongoRunner.Run(options)) diff --git a/src/EphemeralMongo.Core/BaseMongoProcess.cs b/src/EphemeralMongo.Core/BaseMongoProcess.cs index df68ebf..89f5d6b 100644 --- a/src/EphemeralMongo.Core/BaseMongoProcess.cs +++ b/src/EphemeralMongo.Core/BaseMongoProcess.cs @@ -8,6 +8,11 @@ protected BaseMongoProcess(MongoRunnerOptions options, string executablePath, st { this.Options = options; + if (options.KillMongoProcessesWhenCurrentProcessExits) + { + NativeMethods.EnsureMongoProcessesAreKilledWhenCurrentProcessIsKilled(); + } + var processStartInfo = new ProcessStartInfo { FileName = executablePath, diff --git a/src/EphemeralMongo.Core/EphemeralMongo.Core.csproj b/src/EphemeralMongo.Core/EphemeralMongo.Core.csproj index 4c99ebe..5967f2d 100644 --- a/src/EphemeralMongo.Core/EphemeralMongo.Core.csproj +++ b/src/EphemeralMongo.Core/EphemeralMongo.Core.csproj @@ -7,6 +7,7 @@ true snupkg EphemeralMongo + true @@ -15,6 +16,9 @@ all runtime; build; native; contentfiles; analyzers + + all + diff --git a/src/EphemeralMongo.Core/MongoRunnerOptions.cs b/src/EphemeralMongo.Core/MongoRunnerOptions.cs index 3fdae78..a1b4352 100644 --- a/src/EphemeralMongo.Core/MongoRunnerOptions.cs +++ b/src/EphemeralMongo.Core/MongoRunnerOptions.cs @@ -6,6 +6,7 @@ public sealed class MongoRunnerOptions private string? _binaryDirectory; private TimeSpan _connectionTimeout = TimeSpan.FromSeconds(30); private TimeSpan _replicaSetSetupTimeout = TimeSpan.FromSeconds(10); + private int? _mongoPort; public MongoRunnerOptions() { @@ -22,48 +23,94 @@ public MongoRunnerOptions(MongoRunnerOptions options) this._binaryDirectory = options._binaryDirectory; this._connectionTimeout = options._connectionTimeout; this._replicaSetSetupTimeout = options._replicaSetSetupTimeout; + this._mongoPort = options._mongoPort; this.AdditionalArguments = options.AdditionalArguments; this.UseSingleNodeReplicaSet = options.UseSingleNodeReplicaSet; this.StandardOuputLogger = options.StandardOuputLogger; this.StandardErrorLogger = options.StandardErrorLogger; this.ReplicaSetName = options.ReplicaSetName; - this.MongoPort = options.MongoPort; + this.KillMongoProcessesWhenCurrentProcessExits = options.KillMongoProcessesWhenCurrentProcessExits; } + /// + /// The directory where the mongod instance stores its data. If not specified, a temporary directory will be used. + /// + /// The path is invalid. + /// public string? DataDirectory { get => this._dataDirectory; set => this._dataDirectory = CheckDirectoryPathFormat(value) is { } ex ? throw new ArgumentException(nameof(this.DataDirectory), ex) : value; } + /// + /// The directory where your own MongoDB binaries can be found (mongod, mongoexport and mongoimport). + /// + /// The path is invalid. public string? BinaryDirectory { get => this._binaryDirectory; set => this._binaryDirectory = CheckDirectoryPathFormat(value) is { } ex ? throw new ArgumentException(nameof(this.BinaryDirectory), ex) : value; } + /// + /// Additional mongod CLI arguments. + /// + /// public string? AdditionalArguments { get; set; } + /// + /// Maximum timespan to wait for mongod process to be ready to accept connections. + /// + /// The timeout cannot be negative. public TimeSpan ConnectionTimeout { get => this._connectionTimeout; set => this._connectionTimeout = value >= TimeSpan.Zero ? value : throw new ArgumentOutOfRangeException(nameof(this.ConnectionTimeout)); } + /// + /// Whether to create a single node replica set or use a standalone mongod instance. + /// public bool UseSingleNodeReplicaSet { get; set; } + /// + /// Maximum timespan to wait for the replica set to accept database writes. + /// + /// The timeout cannot be negative. public TimeSpan ReplicaSetSetupTimeout { get => this._replicaSetSetupTimeout; set => this._replicaSetSetupTimeout = value >= TimeSpan.Zero ? value : throw new ArgumentOutOfRangeException(nameof(this.ReplicaSetSetupTimeout)); } + /// + /// A delegate that provides access to any MongodDB-related process standard output. + /// public Logger? StandardOuputLogger { get; set; } + /// + /// A delegate that provides access to any MongodDB-related process error output. + /// public Logger? StandardErrorLogger { get; set; } - public int? MongoPort { get; set; } + /// + /// The mongod port to use. If not specified, a random available port will be used. + /// + /// The port must be greater than zero. + public int? MongoPort + { + get => this._mongoPort; + set => this._mongoPort = value is not <= 0 ? value : throw new ArgumentOutOfRangeException(nameof(this.MongoPort)); + } + + /// + /// EXPERIMENTAL - Only works on Windows and modern .NET (netcoreapp3.1, net5.0, net6.0, net7.0 and so on): + /// Ensures that all MongoDB child processes are killed when the current process is prematurely killed, + /// for instance when killed from the task manager or the IDE unit tests window. + /// + public bool KillMongoProcessesWhenCurrentProcessExits { get; set; } // Internal properties start here internal string ReplicaSetName { get; set; } = "singleNodeReplSet"; diff --git a/src/EphemeralMongo.Core/NativeMethods.cs b/src/EphemeralMongo.Core/NativeMethods.cs new file mode 100644 index 0000000..9b1ec0c --- /dev/null +++ b/src/EphemeralMongo.Core/NativeMethods.cs @@ -0,0 +1,106 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using Windows.Win32; +using Windows.Win32.Security; +using Windows.Win32.System.JobObjects; +using Microsoft.Win32.SafeHandles; + +namespace EphemeralMongo; + +internal static class NativeMethods +{ + private static readonly object _createJobObjectLock = new object(); + private static SafeFileHandle? _jobObjectHandle; + + public static void EnsureMongoProcessesAreKilledWhenCurrentProcessIsKilled() + { + // We only support this feature on Windows and modern .NET (netcoreapp3.1, net5.0, net6.0, net7.0 and so on): + // - Job objects are Windows-specific + // - On .NET Framework, the current process crashes even if we don't dispose the job object handle (tested with in test project while running "dotnet test") + // + // "A job object allows groups of processes to be managed as a unit. + // Operations performed on a job object affect all processes associated with the job object. + // Examples include [...] or terminating all processes associated with a job." + // See: https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects + if (IsWindows() && !IsNetFramework()) + { + CreateSingletonJobObject(); + } + } + + private static bool IsWindows() => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + // This way of detecting if running on .NET Framework is also used in .NET runtime tests, see: + // https://github.com/dotnet/runtime/blob/v6.0.0/src/libraries/Common/tests/TestUtilities/System/PlatformDetection.Windows.cs#L21 + private static bool IsNetFramework() => RuntimeInformation.FrameworkDescription.StartsWith(".NET Framework", StringComparison.OrdinalIgnoreCase); + + private static unsafe void CreateSingletonJobObject() + { + // Using a static job object ensures there's a single job object created for the current process. + // Any MongoDB-related process that we will be created later on will be associated to the current process through this job object. + // If the current process dies prematurely, all MongoDB-related processes will also be killed. + // However, we never dispose this job object handle otherwise it would immediately kill the current process too. + if (_jobObjectHandle != null) + { + return; + } + + lock (_createJobObjectLock) + { + if (_jobObjectHandle != null) + { + return; + } + + // https://www.meziantou.net/killing-all-child-processes-when-the-parent-exits-job-object.htm + var attributes = new SECURITY_ATTRIBUTES + { + bInheritHandle = false, + lpSecurityDescriptor = IntPtr.Zero.ToPointer(), + nLength = (uint)Marshal.SizeOf(typeof(SECURITY_ATTRIBUTES)), + }; + + SafeFileHandle? jobHandle = null; + + try + { + jobHandle = PInvoke.CreateJobObject(attributes, lpName: null); + + if (jobHandle.IsInvalid) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + // Configure the job object to kill all child processes when the root process is killed + var info = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + BasicLimitInformation = new JOBOBJECT_BASIC_LIMIT_INFORMATION + { + // Kill all processes associated to the job when the last handle is closed + LimitFlags = JOB_OBJECT_LIMIT.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }, + }; + + if (!PInvoke.SetInformationJobObject(jobHandle, JOBOBJECTINFOCLASS.JobObjectExtendedLimitInformation, &info, (uint)Marshal.SizeOf())) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + // Assign the job object to the current process + if (!PInvoke.AssignProcessToJobObject(jobHandle, Process.GetCurrentProcess().SafeHandle)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + _jobObjectHandle = jobHandle; + } + catch + { + // It's safe to dispose the job object handle here because it was not yet associated to the current process + jobHandle?.Dispose(); + throw; + } + } + } +} \ No newline at end of file diff --git a/src/EphemeralMongo.Core/NativeMethods.txt b/src/EphemeralMongo.Core/NativeMethods.txt new file mode 100644 index 0000000..3f75f5c --- /dev/null +++ b/src/EphemeralMongo.Core/NativeMethods.txt @@ -0,0 +1,4 @@ +CreateJobObject +SetInformationJobObject +AssignProcessToJobObject +JOBOBJECT_EXTENDED_LIMIT_INFORMATION \ No newline at end of file diff --git a/src/EphemeralMongo.Core/PublicAPI.Shipped.txt b/src/EphemeralMongo.Core/PublicAPI.Shipped.txt index 5339480..a5e28e6 100644 --- a/src/EphemeralMongo.Core/PublicAPI.Shipped.txt +++ b/src/EphemeralMongo.Core/PublicAPI.Shipped.txt @@ -26,4 +26,6 @@ EphemeralMongo.MongoRunnerOptions.UseSingleNodeReplicaSet.get -> bool EphemeralMongo.MongoRunnerOptions.UseSingleNodeReplicaSet.set -> void EphemeralMongo.MongoRunnerOptions.MongoPort.get -> int? EphemeralMongo.MongoRunnerOptions.MongoPort.set -> void +EphemeralMongo.MongoRunnerOptions.KillMongoProcessesWhenCurrentProcessExits.get -> bool +EphemeralMongo.MongoRunnerOptions.KillMongoProcessesWhenCurrentProcessExits.set -> void static EphemeralMongo.MongoRunner.Run(EphemeralMongo.MongoRunnerOptions? options = null) -> EphemeralMongo.IMongoRunner! \ No newline at end of file diff --git a/src/EphemeralMongo.Runtimes/EphemeralMongo.runtime.targets b/src/EphemeralMongo.Runtimes/EphemeralMongo.runtime.targets index 3379e77..7c34c34 100644 --- a/src/EphemeralMongo.Runtimes/EphemeralMongo.runtime.targets +++ b/src/EphemeralMongo.Runtimes/EphemeralMongo.runtime.targets @@ -7,6 +7,7 @@ .NET wrapper for MongoDB $(FullMongoVersion) built for .NET Standard 2.0. README.md native + true $(NoWarn);NU5127