Skip to content

Commit

Permalink
Ensure MongoDB child processes are killed when current process is pre…
Browse files Browse the repository at this point in the history
…maturely killed (#25)
  • Loading branch information
asimmon authored Mar 8, 2023
1 parent 3ba539c commit 585f59c
Show file tree
Hide file tree
Showing 12 changed files with 189 additions and 13 deletions.
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ jobs:
- uses: actions/setup-dotnet@v3
with:
dotnet-version: |
3.1.x
6.0.x
- uses: actions/download-artifact@v3
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ jobs:
- uses: actions/setup-dotnet@v3
with:
dotnet-version: |
3.1.x
6.0.x
- uses: actions/download-artifact@v3
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/) |


Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net462;netcoreapp3.1;net6.0</TargetFrameworks>
<TargetFrameworks>net462;net6.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="GSoft.Extensions.Xunit" Version="1.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
<PackageReference Include="ShareGate.Extensions.Xunit" Version="0.1.2" />
<PackageReference Include="Microsoft.TestPlatform.ObjectModel" Version="17.5.0" Condition=" '$(OS)' != 'Windows_NT' " />
<PackageReference Include="System.Memory" Version="4.5.5" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.TestPlatform.ObjectModel" Version="17.5.0" Condition=" '$(OS)' != 'Windows_NT' " />
</ItemGroup>

<ItemGroup>
Expand Down
11 changes: 7 additions & 4 deletions src/EphemeralMongo.Core.Tests/MongoRunnerTests.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
using GSoft.Extensions.Xunit;
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using ShareGate.Extensions.Xunit;
using Xunit;
using Xunit.Abstractions;

namespace EphemeralMongo.Core.Tests;

public class MongoRunnerTests : BaseIntegrationTest
{
public MongoRunnerTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper)
public MongoRunnerTests(EmptyIntegrationFixture fixture, ITestOutputHelper testOutputHelper)
: base(fixture, testOutputHelper)
{
}

Expand All @@ -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;
Expand Down Expand Up @@ -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))
Expand Down
5 changes: 5 additions & 0 deletions src/EphemeralMongo.Core/BaseMongoProcess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/EphemeralMongo.Core/EphemeralMongo.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<RootNamespace>EphemeralMongo</RootNamespace>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

<ItemGroup>
Expand All @@ -15,6 +16,9 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Windows.CsWin32" Version="0.2.188-beta">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
Expand Down
51 changes: 49 additions & 2 deletions src/EphemeralMongo.Core/MongoRunnerOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -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;
}

/// <summary>
/// The directory where the mongod instance stores its data. If not specified, a temporary directory will be used.
/// </summary>
/// <exception cref="ArgumentException">The path is invalid.</exception>
/// <seealso cref="https://www.mongodb.com/docs/manual/reference/program/mongod/#std-option-mongod.--dbpath"/>
public string? DataDirectory
{
get => this._dataDirectory;
set => this._dataDirectory = CheckDirectoryPathFormat(value) is { } ex ? throw new ArgumentException(nameof(this.DataDirectory), ex) : value;
}

/// <summary>
/// The directory where your own MongoDB binaries can be found (mongod, mongoexport and mongoimport).
/// </summary>
/// <exception cref="ArgumentException">The path is invalid.</exception>
public string? BinaryDirectory
{
get => this._binaryDirectory;
set => this._binaryDirectory = CheckDirectoryPathFormat(value) is { } ex ? throw new ArgumentException(nameof(this.BinaryDirectory), ex) : value;
}

/// <summary>
/// Additional mongod CLI arguments.
/// </summary>
/// <seealso cref="https://www.mongodb.com/docs/manual/reference/program/mongod/#options"/>
public string? AdditionalArguments { get; set; }

/// <summary>
/// Maximum timespan to wait for mongod process to be ready to accept connections.
/// </summary>
/// <exception cref="ArgumentOutOfRangeException">The timeout cannot be negative.</exception>
public TimeSpan ConnectionTimeout
{
get => this._connectionTimeout;
set => this._connectionTimeout = value >= TimeSpan.Zero ? value : throw new ArgumentOutOfRangeException(nameof(this.ConnectionTimeout));
}

/// <summary>
/// Whether to create a single node replica set or use a standalone mongod instance.
/// </summary>
public bool UseSingleNodeReplicaSet { get; set; }

/// <summary>
/// Maximum timespan to wait for the replica set to accept database writes.
/// </summary>
/// <exception cref="ArgumentOutOfRangeException">The timeout cannot be negative.</exception>
public TimeSpan ReplicaSetSetupTimeout
{
get => this._replicaSetSetupTimeout;
set => this._replicaSetSetupTimeout = value >= TimeSpan.Zero ? value : throw new ArgumentOutOfRangeException(nameof(this.ReplicaSetSetupTimeout));
}

/// <summary>
/// A delegate that provides access to any MongodDB-related process standard output.
/// </summary>
public Logger? StandardOuputLogger { get; set; }

/// <summary>
/// A delegate that provides access to any MongodDB-related process error output.
/// </summary>
public Logger? StandardErrorLogger { get; set; }

public int? MongoPort { get; set; }
/// <summary>
/// The mongod port to use. If not specified, a random available port will be used.
/// </summary>
/// <exception cref="ArgumentOutOfRangeException">The port must be greater than zero.</exception>
public int? MongoPort
{
get => this._mongoPort;
set => this._mongoPort = value is not <= 0 ? value : throw new ArgumentOutOfRangeException(nameof(this.MongoPort));
}

/// <summary>
/// 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.
/// </summary>
public bool KillMongoProcessesWhenCurrentProcessExits { get; set; }

// Internal properties start here
internal string ReplicaSetName { get; set; } = "singleNodeReplSet";
Expand Down
106 changes: 106 additions & 0 deletions src/EphemeralMongo.Core/NativeMethods.cs
Original file line number Diff line number Diff line change
@@ -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<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>()))
{
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;
}
}
}
}
4 changes: 4 additions & 0 deletions src/EphemeralMongo.Core/NativeMethods.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
CreateJobObject
SetInformationJobObject
AssignProcessToJobObject
JOBOBJECT_EXTENDED_LIMIT_INFORMATION
2 changes: 2 additions & 0 deletions src/EphemeralMongo.Core/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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!
1 change: 1 addition & 0 deletions src/EphemeralMongo.Runtimes/EphemeralMongo.runtime.targets
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<Description>.NET wrapper for MongoDB $(FullMongoVersion) built for .NET Standard 2.0.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageTags>native</PackageTags>
<NoDefaultExcludes>true</NoDefaultExcludes>
<NoWarn>$(NoWarn);NU5127</NoWarn>
</PropertyGroup>

Expand Down

0 comments on commit 585f59c

Please sign in to comment.