Skip to content

Commit 585f59c

Browse files
authored
Ensure MongoDB child processes are killed when current process is prematurely killed (#25)
1 parent 3ba539c commit 585f59c

File tree

12 files changed

+189
-13
lines changed

12 files changed

+189
-13
lines changed

.github/workflows/ci.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ jobs:
4444
- uses: actions/setup-dotnet@v3
4545
with:
4646
dotnet-version: |
47-
3.1.x
4847
6.0.x
4948
5049
- uses: actions/download-artifact@v3

.github/workflows/release.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ jobs:
4040
- uses: actions/setup-dotnet@v3
4141
with:
4242
dotnet-version: |
43-
3.1.x
4443
6.0.x
4544
4645
- uses: actions/download-artifact@v3

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ This project is very much inspired from [Mongo2Go](https://github.com/Mongo2Go/M
2525

2626
| Package | Description | Link |
2727
|---------------------|-----------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------|
28-
| **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/) |
29-
| **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/) |
28+
| **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/) |
29+
| **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/) |
3030
| **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/) |
3131

3232

@@ -47,6 +47,11 @@ var options = new MongoRunnerOptions
4747
ReplicaSetSetupTimeout = TimeSpan.FromSeconds(5), // Default: 10 seconds
4848
AdditionalArguments = "--quiet", // Default: null
4949
MongoPort = 27017, // Default: random available port
50+
51+
// EXPERIMENTAL - Only works on Windows and modern .NET (netcoreapp3.1, net5.0, net6.0, net7.0 and so on):
52+
// Ensures that all MongoDB child processes are killed when the current process is prematurely killed,
53+
// for instance when killed from the task manager or the IDE unit tests window.
54+
KillMongoProcessesWhenCurrentProcessExits = true // Default: false
5055
};
5156

5257
// Disposing the runner will kill the MongoDB process (mongod) and delete the associated data directory

src/EphemeralMongo.Core.Tests/EphemeralMongo.Core.Tests.csproj

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22
<PropertyGroup>
3-
<TargetFrameworks>net462;netcoreapp3.1;net6.0</TargetFrameworks>
3+
<TargetFrameworks>net462;net6.0</TargetFrameworks>
44
<ImplicitUsings>enable</ImplicitUsings>
55
<Nullable>enable</Nullable>
66
<IsPackable>false</IsPackable>
77
</PropertyGroup>
88

99
<ItemGroup>
10+
<PackageReference Include="GSoft.Extensions.Xunit" Version="1.0.1" />
1011
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
11-
<PackageReference Include="ShareGate.Extensions.Xunit" Version="0.1.2" />
12+
<PackageReference Include="Microsoft.TestPlatform.ObjectModel" Version="17.5.0" Condition=" '$(OS)' != 'Windows_NT' " />
13+
<PackageReference Include="System.Memory" Version="4.5.5" />
1214
<PackageReference Include="xunit" Version="2.4.2" />
1315
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
1416
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
1517
<PrivateAssets>all</PrivateAssets>
1618
</PackageReference>
17-
<PackageReference Include="Microsoft.TestPlatform.ObjectModel" Version="17.5.0" Condition=" '$(OS)' != 'Windows_NT' " />
1819
</ItemGroup>
1920

2021
<ItemGroup>

src/EphemeralMongo.Core.Tests/MongoRunnerTests.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1+
using GSoft.Extensions.Xunit;
12
using Microsoft.Extensions.Logging;
23
using MongoDB.Driver;
3-
using ShareGate.Extensions.Xunit;
44
using Xunit;
55
using Xunit.Abstractions;
66

77
namespace EphemeralMongo.Core.Tests;
88

99
public class MongoRunnerTests : BaseIntegrationTest
1010
{
11-
public MongoRunnerTests(ITestOutputHelper testOutputHelper)
12-
: base(testOutputHelper)
11+
public MongoRunnerTests(EmptyIntegrationFixture fixture, ITestOutputHelper testOutputHelper)
12+
: base(fixture, testOutputHelper)
1313
{
1414
}
1515

@@ -21,7 +21,8 @@ public void Run_Fails_When_BinaryDirectory_Does_Not_Exist()
2121
StandardOuputLogger = x => this.Logger.LogInformation("{X}", x),
2222
StandardErrorLogger = x => this.Logger.LogInformation("{X}", x),
2323
BinaryDirectory = Guid.NewGuid().ToString(),
24-
AdditionalArguments = string.Empty,
24+
AdditionalArguments = "--quiet",
25+
KillMongoProcessesWhenCurrentProcessExits = true,
2526
};
2627

2728
IMongoRunner? runner = null;
@@ -51,6 +52,8 @@ public void Import_Export_Works(bool useSingleNodeReplicaSet)
5152
UseSingleNodeReplicaSet = useSingleNodeReplicaSet,
5253
StandardOuputLogger = x => this.Logger.LogInformation("{X}", x),
5354
StandardErrorLogger = x => this.Logger.LogInformation("{X}", x),
55+
AdditionalArguments = "--quiet",
56+
KillMongoProcessesWhenCurrentProcessExits = true,
5457
};
5558

5659
using (var runner = MongoRunner.Run(options))

src/EphemeralMongo.Core/BaseMongoProcess.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ protected BaseMongoProcess(MongoRunnerOptions options, string executablePath, st
88
{
99
this.Options = options;
1010

11+
if (options.KillMongoProcessesWhenCurrentProcessExits)
12+
{
13+
NativeMethods.EnsureMongoProcessesAreKilledWhenCurrentProcessIsKilled();
14+
}
15+
1116
var processStartInfo = new ProcessStartInfo
1217
{
1318
FileName = executablePath,

src/EphemeralMongo.Core/EphemeralMongo.Core.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
<IncludeSymbols>true</IncludeSymbols>
88
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
99
<RootNamespace>EphemeralMongo</RootNamespace>
10+
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
1011
</PropertyGroup>
1112

1213
<ItemGroup>
@@ -15,6 +16,9 @@
1516
<PrivateAssets>all</PrivateAssets>
1617
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
1718
</PackageReference>
19+
<PackageReference Include="Microsoft.Windows.CsWin32" Version="0.2.188-beta">
20+
<PrivateAssets>all</PrivateAssets>
21+
</PackageReference>
1822
</ItemGroup>
1923

2024
<ItemGroup>

src/EphemeralMongo.Core/MongoRunnerOptions.cs

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ public sealed class MongoRunnerOptions
66
private string? _binaryDirectory;
77
private TimeSpan _connectionTimeout = TimeSpan.FromSeconds(30);
88
private TimeSpan _replicaSetSetupTimeout = TimeSpan.FromSeconds(10);
9+
private int? _mongoPort;
910

1011
public MongoRunnerOptions()
1112
{
@@ -22,48 +23,94 @@ public MongoRunnerOptions(MongoRunnerOptions options)
2223
this._binaryDirectory = options._binaryDirectory;
2324
this._connectionTimeout = options._connectionTimeout;
2425
this._replicaSetSetupTimeout = options._replicaSetSetupTimeout;
26+
this._mongoPort = options._mongoPort;
2527

2628
this.AdditionalArguments = options.AdditionalArguments;
2729
this.UseSingleNodeReplicaSet = options.UseSingleNodeReplicaSet;
2830
this.StandardOuputLogger = options.StandardOuputLogger;
2931
this.StandardErrorLogger = options.StandardErrorLogger;
3032
this.ReplicaSetName = options.ReplicaSetName;
31-
this.MongoPort = options.MongoPort;
33+
this.KillMongoProcessesWhenCurrentProcessExits = options.KillMongoProcessesWhenCurrentProcessExits;
3234
}
3335

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

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

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

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

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

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

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

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

66-
public int? MongoPort { get; set; }
98+
/// <summary>
99+
/// The mongod port to use. If not specified, a random available port will be used.
100+
/// </summary>
101+
/// <exception cref="ArgumentOutOfRangeException">The port must be greater than zero.</exception>
102+
public int? MongoPort
103+
{
104+
get => this._mongoPort;
105+
set => this._mongoPort = value is not <= 0 ? value : throw new ArgumentOutOfRangeException(nameof(this.MongoPort));
106+
}
107+
108+
/// <summary>
109+
/// EXPERIMENTAL - Only works on Windows and modern .NET (netcoreapp3.1, net5.0, net6.0, net7.0 and so on):
110+
/// Ensures that all MongoDB child processes are killed when the current process is prematurely killed,
111+
/// for instance when killed from the task manager or the IDE unit tests window.
112+
/// </summary>
113+
public bool KillMongoProcessesWhenCurrentProcessExits { get; set; }
67114

68115
// Internal properties start here
69116
internal string ReplicaSetName { get; set; } = "singleNodeReplSet";
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
using System.ComponentModel;
2+
using System.Diagnostics;
3+
using System.Runtime.InteropServices;
4+
using Windows.Win32;
5+
using Windows.Win32.Security;
6+
using Windows.Win32.System.JobObjects;
7+
using Microsoft.Win32.SafeHandles;
8+
9+
namespace EphemeralMongo;
10+
11+
internal static class NativeMethods
12+
{
13+
private static readonly object _createJobObjectLock = new object();
14+
private static SafeFileHandle? _jobObjectHandle;
15+
16+
public static void EnsureMongoProcessesAreKilledWhenCurrentProcessIsKilled()
17+
{
18+
// We only support this feature on Windows and modern .NET (netcoreapp3.1, net5.0, net6.0, net7.0 and so on):
19+
// - Job objects are Windows-specific
20+
// - 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")
21+
//
22+
// "A job object allows groups of processes to be managed as a unit.
23+
// Operations performed on a job object affect all processes associated with the job object.
24+
// Examples include [...] or terminating all processes associated with a job."
25+
// See: https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects
26+
if (IsWindows() && !IsNetFramework())
27+
{
28+
CreateSingletonJobObject();
29+
}
30+
}
31+
32+
private static bool IsWindows() => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
33+
34+
// This way of detecting if running on .NET Framework is also used in .NET runtime tests, see:
35+
// https://github.com/dotnet/runtime/blob/v6.0.0/src/libraries/Common/tests/TestUtilities/System/PlatformDetection.Windows.cs#L21
36+
private static bool IsNetFramework() => RuntimeInformation.FrameworkDescription.StartsWith(".NET Framework", StringComparison.OrdinalIgnoreCase);
37+
38+
private static unsafe void CreateSingletonJobObject()
39+
{
40+
// Using a static job object ensures there's a single job object created for the current process.
41+
// Any MongoDB-related process that we will be created later on will be associated to the current process through this job object.
42+
// If the current process dies prematurely, all MongoDB-related processes will also be killed.
43+
// However, we never dispose this job object handle otherwise it would immediately kill the current process too.
44+
if (_jobObjectHandle != null)
45+
{
46+
return;
47+
}
48+
49+
lock (_createJobObjectLock)
50+
{
51+
if (_jobObjectHandle != null)
52+
{
53+
return;
54+
}
55+
56+
// https://www.meziantou.net/killing-all-child-processes-when-the-parent-exits-job-object.htm
57+
var attributes = new SECURITY_ATTRIBUTES
58+
{
59+
bInheritHandle = false,
60+
lpSecurityDescriptor = IntPtr.Zero.ToPointer(),
61+
nLength = (uint)Marshal.SizeOf(typeof(SECURITY_ATTRIBUTES)),
62+
};
63+
64+
SafeFileHandle? jobHandle = null;
65+
66+
try
67+
{
68+
jobHandle = PInvoke.CreateJobObject(attributes, lpName: null);
69+
70+
if (jobHandle.IsInvalid)
71+
{
72+
throw new Win32Exception(Marshal.GetLastWin32Error());
73+
}
74+
75+
// Configure the job object to kill all child processes when the root process is killed
76+
var info = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION
77+
{
78+
BasicLimitInformation = new JOBOBJECT_BASIC_LIMIT_INFORMATION
79+
{
80+
// Kill all processes associated to the job when the last handle is closed
81+
LimitFlags = JOB_OBJECT_LIMIT.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
82+
},
83+
};
84+
85+
if (!PInvoke.SetInformationJobObject(jobHandle, JOBOBJECTINFOCLASS.JobObjectExtendedLimitInformation, &info, (uint)Marshal.SizeOf<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>()))
86+
{
87+
throw new Win32Exception(Marshal.GetLastWin32Error());
88+
}
89+
90+
// Assign the job object to the current process
91+
if (!PInvoke.AssignProcessToJobObject(jobHandle, Process.GetCurrentProcess().SafeHandle))
92+
{
93+
throw new Win32Exception(Marshal.GetLastWin32Error());
94+
}
95+
96+
_jobObjectHandle = jobHandle;
97+
}
98+
catch
99+
{
100+
// It's safe to dispose the job object handle here because it was not yet associated to the current process
101+
jobHandle?.Dispose();
102+
throw;
103+
}
104+
}
105+
}
106+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
CreateJobObject
2+
SetInformationJobObject
3+
AssignProcessToJobObject
4+
JOBOBJECT_EXTENDED_LIMIT_INFORMATION

src/EphemeralMongo.Core/PublicAPI.Shipped.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,6 @@ EphemeralMongo.MongoRunnerOptions.UseSingleNodeReplicaSet.get -> bool
2626
EphemeralMongo.MongoRunnerOptions.UseSingleNodeReplicaSet.set -> void
2727
EphemeralMongo.MongoRunnerOptions.MongoPort.get -> int?
2828
EphemeralMongo.MongoRunnerOptions.MongoPort.set -> void
29+
EphemeralMongo.MongoRunnerOptions.KillMongoProcessesWhenCurrentProcessExits.get -> bool
30+
EphemeralMongo.MongoRunnerOptions.KillMongoProcessesWhenCurrentProcessExits.set -> void
2931
static EphemeralMongo.MongoRunner.Run(EphemeralMongo.MongoRunnerOptions? options = null) -> EphemeralMongo.IMongoRunner!

src/EphemeralMongo.Runtimes/EphemeralMongo.runtime.targets

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
<Description>.NET wrapper for MongoDB $(FullMongoVersion) built for .NET Standard 2.0.</Description>
88
<PackageReadmeFile>README.md</PackageReadmeFile>
99
<PackageTags>native</PackageTags>
10+
<NoDefaultExcludes>true</NoDefaultExcludes>
1011
<NoWarn>$(NoWarn);NU5127</NoWarn>
1112
</PropertyGroup>
1213

0 commit comments

Comments
 (0)