Skip to content

Commit ff4d67c

Browse files
authored
Feat/fix mutexes (#2711)
* Add shared mutex factory for cross-platform security * Add mutex test tool
1 parent 86eefe8 commit ff4d67c

File tree

6 files changed

+530
-15
lines changed

6 files changed

+530
-15
lines changed
Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
using System.Diagnostics;
2+
using System.IO;
3+
using System.Reflection;
4+
using System.Text;
5+
using System.Threading;
6+
using LiteDB;
7+
8+
var executablePath = Environment.ProcessPath ?? throw new InvalidOperationException("ProcessPath could not be determined.");
9+
var options = HarnessOptions.Parse(args, executablePath);
10+
11+
if (options.Mode == HarnessMode.Child)
12+
{
13+
RunChild(options);
14+
return;
15+
}
16+
17+
RunParent(options);
18+
return;
19+
20+
void RunParent(HarnessOptions options)
21+
{
22+
Console.WriteLine($"[parent] creating shared mutex '{options.MutexName}'");
23+
if (options.UsePsExec)
24+
{
25+
Console.WriteLine($"[parent] PsExec mode enabled (session {options.SessionId}, tool: {options.PsExecPath})");
26+
}
27+
28+
Directory.CreateDirectory(options.LogDirectory);
29+
30+
using var mutex = CreateSharedMutex(options.MutexName);
31+
32+
Console.WriteLine("[parent] acquiring mutex");
33+
mutex.WaitOne();
34+
Console.WriteLine("[parent] mutex acquired");
35+
36+
Console.WriteLine("[parent] spawning child with 2s timeout while mutex is held");
37+
var probeResult = StartChildProcess(options, waitMilliseconds: 2000, "probe");
38+
Console.WriteLine(probeResult);
39+
40+
Console.WriteLine("[parent] releasing mutex");
41+
mutex.ReleaseMutex();
42+
43+
Console.WriteLine("[parent] spawning child waiting without timeout after release");
44+
var acquireResult = StartChildProcess(options, waitMilliseconds: -1, "post-release");
45+
Console.WriteLine(acquireResult);
46+
47+
Console.WriteLine("[parent] experiment finished");
48+
}
49+
50+
string StartChildProcess(HarnessOptions options, int waitMilliseconds, string label)
51+
{
52+
var logPath = Path.Combine(options.LogDirectory, $"{label}-{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}-{Guid.NewGuid():N}.log");
53+
54+
var psi = BuildChildStartInfo(options, waitMilliseconds, logPath);
55+
56+
using var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to spawn child process.");
57+
58+
var output = new StringBuilder();
59+
process.OutputDataReceived += (_, e) =>
60+
{
61+
if (e.Data != null)
62+
{
63+
output.AppendLine(e.Data);
64+
}
65+
};
66+
process.ErrorDataReceived += (_, e) =>
67+
{
68+
if (e.Data != null)
69+
{
70+
output.AppendLine("[stderr] " + e.Data);
71+
}
72+
};
73+
74+
process.BeginOutputReadLine();
75+
process.BeginErrorReadLine();
76+
77+
if (!process.WaitForExit(10000))
78+
{
79+
process.Kill(entireProcessTree: true);
80+
throw new TimeoutException("Child process exceeded wait timeout.");
81+
}
82+
83+
process.WaitForExit();
84+
85+
if (File.Exists(logPath))
86+
{
87+
output.AppendLine("[child log]");
88+
output.Append(File.ReadAllText(logPath));
89+
File.Delete(logPath);
90+
}
91+
else
92+
{
93+
output.AppendLine("[child log missing]");
94+
}
95+
96+
return output.ToString();
97+
}
98+
99+
ProcessStartInfo BuildChildStartInfo(HarnessOptions options, int waitMilliseconds, string logPath)
100+
{
101+
if (!options.UsePsExec)
102+
{
103+
var directStartInfo = new ProcessStartInfo(options.ExecutablePath)
104+
{
105+
RedirectStandardOutput = true,
106+
RedirectStandardError = true,
107+
UseShellExecute = false
108+
};
109+
110+
directStartInfo.ArgumentList.Add("child");
111+
directStartInfo.ArgumentList.Add(options.MutexName);
112+
directStartInfo.ArgumentList.Add(waitMilliseconds.ToString());
113+
directStartInfo.ArgumentList.Add(logPath);
114+
115+
return directStartInfo;
116+
}
117+
118+
if (!File.Exists(options.PsExecPath))
119+
{
120+
throw new FileNotFoundException("PsExec executable could not be located.", options.PsExecPath);
121+
}
122+
123+
var psi = new ProcessStartInfo(options.PsExecPath)
124+
{
125+
RedirectStandardOutput = true,
126+
RedirectStandardError = true,
127+
UseShellExecute = false
128+
};
129+
130+
psi.ArgumentList.Add("-accepteula");
131+
psi.ArgumentList.Add("-nobanner");
132+
psi.ArgumentList.Add("-i");
133+
psi.ArgumentList.Add(options.SessionId.ToString());
134+
135+
if (options.RunAsSystem)
136+
{
137+
psi.ArgumentList.Add("-s");
138+
}
139+
140+
psi.ArgumentList.Add(options.ExecutablePath);
141+
psi.ArgumentList.Add("child");
142+
psi.ArgumentList.Add(options.MutexName);
143+
psi.ArgumentList.Add(waitMilliseconds.ToString());
144+
psi.ArgumentList.Add(logPath);
145+
146+
return psi;
147+
}
148+
149+
void RunChild(HarnessOptions options)
150+
{
151+
if (!string.IsNullOrEmpty(options.LogPath))
152+
{
153+
var directory = Path.GetDirectoryName(options.LogPath);
154+
if (!string.IsNullOrEmpty(directory))
155+
{
156+
Directory.CreateDirectory(directory);
157+
}
158+
}
159+
160+
void Log(string message)
161+
{
162+
Console.WriteLine(message);
163+
if (!string.IsNullOrEmpty(options.LogPath))
164+
{
165+
File.AppendAllText(options.LogPath, message + Environment.NewLine);
166+
}
167+
}
168+
169+
using var mutex = CreateSharedMutex(options.MutexName);
170+
Log($"[child {Environment.ProcessId}] attempting to acquire mutex '{options.MutexName}' (wait={options.ChildWaitMilliseconds}ms)");
171+
172+
var sw = Stopwatch.StartNew();
173+
bool acquired;
174+
175+
if (options.ChildWaitMilliseconds >= 0)
176+
{
177+
acquired = mutex.WaitOne(options.ChildWaitMilliseconds);
178+
}
179+
else
180+
{
181+
acquired = mutex.WaitOne();
182+
}
183+
184+
sw.Stop();
185+
186+
Log($"[child {Environment.ProcessId}] acquired={acquired} after {sw.ElapsedMilliseconds}ms");
187+
188+
if (acquired)
189+
{
190+
mutex.ReleaseMutex();
191+
Log($"[child {Environment.ProcessId}] released mutex");
192+
}
193+
}
194+
195+
static Mutex CreateSharedMutex(string name)
196+
{
197+
var liteDbAssembly = typeof(SharedEngine).Assembly;
198+
var factoryType = liteDbAssembly.GetType("LiteDB.SharedMutexFactory", throwOnError: true)
199+
?? throw new InvalidOperationException("Could not locate SharedMutexFactory.");
200+
201+
var createMethod = factoryType.GetMethod("Create", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)
202+
?? throw new InvalidOperationException("Could not resolve the Create method on SharedMutexFactory.");
203+
204+
var mutex = createMethod.Invoke(null, new object[] { name }) as Mutex;
205+
206+
if (mutex is null)
207+
{
208+
throw new InvalidOperationException("SharedMutexFactory.Create returned null.");
209+
}
210+
211+
return mutex;
212+
}
213+
214+
internal sealed record HarnessOptions(
215+
HarnessMode Mode,
216+
string MutexName,
217+
bool UsePsExec,
218+
int SessionId,
219+
bool RunAsSystem,
220+
string PsExecPath,
221+
string ExecutablePath,
222+
int ChildWaitMilliseconds,
223+
string? LogPath,
224+
string LogDirectory)
225+
{
226+
public static HarnessOptions Parse(string[] args, string executablePath)
227+
{
228+
bool usePsExec = false;
229+
int sessionId = 0;
230+
string? psExecPath = null;
231+
bool runAsSystem = false;
232+
string? logDirectory = null;
233+
var positional = new List<string>();
234+
235+
for (var i = 0; i < args.Length; i++)
236+
{
237+
var arg = args[i];
238+
239+
if (string.Equals(arg, "--use-psexec", StringComparison.OrdinalIgnoreCase))
240+
{
241+
usePsExec = true;
242+
continue;
243+
}
244+
245+
if (string.Equals(arg, "--session", StringComparison.OrdinalIgnoreCase))
246+
{
247+
if (i + 1 >= args.Length)
248+
{
249+
throw new ArgumentException("Missing session identifier after --session.");
250+
}
251+
252+
sessionId = int.Parse(args[++i]);
253+
continue;
254+
}
255+
256+
if (arg.StartsWith("--psexec-path=", StringComparison.OrdinalIgnoreCase))
257+
{
258+
psExecPath = arg.Substring("--psexec-path=".Length);
259+
continue;
260+
}
261+
262+
if (string.Equals(arg, "--system", StringComparison.OrdinalIgnoreCase))
263+
{
264+
runAsSystem = true;
265+
continue;
266+
}
267+
268+
if (arg.StartsWith("--log-dir=", StringComparison.OrdinalIgnoreCase))
269+
{
270+
logDirectory = arg.Substring("--log-dir=".Length);
271+
continue;
272+
}
273+
274+
positional.Add(arg);
275+
}
276+
277+
var mutexName = positional.Count > 1
278+
? positional[1]
279+
: positional.Count == 1 && !string.Equals(positional[0], "child", StringComparison.OrdinalIgnoreCase)
280+
? positional[0]
281+
: "LiteDB_SharedMutexHarness";
282+
283+
if (positional.Count > 0 && string.Equals(positional[0], "child", StringComparison.OrdinalIgnoreCase))
284+
{
285+
if (positional.Count < 3)
286+
{
287+
throw new ArgumentException("Child invocation expects mutex name and wait duration.");
288+
}
289+
290+
var waitMilliseconds = int.Parse(positional[2]);
291+
var logPath = positional.Count >= 4 ? positional[3] : null;
292+
var childLogDirectory = logDirectory
293+
?? (logPath != null
294+
? Path.GetDirectoryName(logPath) ?? DefaultLogDirectory()
295+
: DefaultLogDirectory());
296+
297+
return new HarnessOptions(
298+
HarnessMode.Child,
299+
positional[1],
300+
UsePsExec: false,
301+
sessionId,
302+
runAsSystem,
303+
psExecPath ?? DefaultPsExecPath(),
304+
executablePath,
305+
waitMilliseconds,
306+
logPath,
307+
childLogDirectory);
308+
}
309+
310+
var resolvedLogDirectory = logDirectory ?? DefaultLogDirectory();
311+
312+
return new HarnessOptions(
313+
HarnessMode.Parent,
314+
mutexName,
315+
usePsExec,
316+
sessionId,
317+
runAsSystem,
318+
psExecPath ?? DefaultPsExecPath(),
319+
executablePath,
320+
ChildWaitMilliseconds: -1,
321+
LogPath: null,
322+
LogDirectory: resolvedLogDirectory);
323+
}
324+
325+
private static string DefaultPsExecPath()
326+
{
327+
var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
328+
return Path.Combine(userProfile, "tools", "Sysinternals", "PsExec.exe");
329+
}
330+
331+
private static string DefaultLogDirectory()
332+
{
333+
return Path.Combine(Path.GetTempPath(), "SharedMutexHarnessLogs");
334+
}
335+
}
336+
337+
internal enum HarnessMode
338+
{
339+
Parent,
340+
Child
341+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# SharedMutexHarness
2+
3+
Console harness that stress-tests LiteDB’s `SharedMutexFactory` across processes and Windows sessions.
4+
5+
## Getting Started
6+
7+
```bash
8+
dotnet run --project SharedMutexHarness/SharedMutexHarness.csproj
9+
```
10+
11+
The parent process acquires the shared mutex, spawns a child that times out, releases the mutex, and spawns a second child that succeeds.
12+
13+
## Cross-Session Probe (PsExec)
14+
15+
Run the parent from an elevated PowerShell so PsExec can install `PSEXESVC`:
16+
17+
```powershell
18+
dotnet run --project SharedMutexHarness/SharedMutexHarness.csproj -- --use-psexec --session 0
19+
```
20+
21+
- `--session <id>` targets a specific interactive session (see `qwinsta` output).
22+
- Add `--system` to launch the child as SYSTEM (optional).
23+
- Use `--log-dir=<path>` to override the default `%TEMP%\SharedMutexHarnessLogs` location.
24+
25+
Each child writes its progress to stdout and a per-run log file; the parent echoes that log when the child completes so you can confirm whether the mutex was acquired.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<OutputType>Exe</OutputType>
4+
<TargetFramework>net8.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
</PropertyGroup>
8+
<ItemGroup>
9+
<ProjectReference Include="..\LiteDB\LiteDB.csproj" />
10+
</ItemGroup>
11+
</Project>
12+

0 commit comments

Comments
 (0)