From b3ffbb9ea2c1f402b303cc4b982a57077a0ed537 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 6 Mar 2026 12:29:07 +1100 Subject: [PATCH 1/2] Add file-lock killing support Introduce an opt-in KillLockingProcess flag through tool definitions and resolved tools, propagate it through DiffTools, Tracker and TrackedMove, and enable it for the MsWordDiff tool. Implement FileLockKiller (uses Windows Restart Manager via rstrtmgr.dll) to identify and kill processes locking a file, and call it from Tracker when a SafeMove fails and the tool opts into killing lockers. Add FileLockKiller tests (FileLockKillerTest) and update .gitignore to ignore TestResults. Note: several method/constructor signatures were extended to accept the new flag. --- .gitignore | 1 + src/DiffEngine/Definition.cs | 1 + src/DiffEngine/DiffTools_Add.cs | 20 +-- src/DiffEngine/Implementation/MsWordDiff.cs | 1 + src/DiffEngine/ResolvedTool.cs | 9 +- .../FileLockKillerTest.cs | 129 ++++++++++++++++++ src/DiffEngineTray/FileLockKiller.cs | 117 ++++++++++++++++ src/DiffEngineTray/TrackedMove.cs | 5 +- src/DiffEngineTray/Tracker.cs | 25 +++- src/Directory.Build.props | 2 +- 10 files changed, 295 insertions(+), 15 deletions(-) create mode 100644 src/DiffEngineTray.Tests/FileLockKillerTest.cs create mode 100644 src/DiffEngineTray/FileLockKiller.cs diff --git a/.gitignore b/.gitignore index c3c65245..38860320 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ obj/ nugets/ .claude/settings.local.json nul +/TestResults diff --git a/src/DiffEngine/Definition.cs b/src/DiffEngine/Definition.cs index f0c617b1..cfe749b5 100644 --- a/src/DiffEngine/Definition.cs +++ b/src/DiffEngine/Definition.cs @@ -12,4 +12,5 @@ public record Definition( OsSupport OsSupport, bool UseShellExecute = true, bool CreateNoWindow = false, + bool KillLockingProcess = false, string? Notes = null); \ No newline at end of file diff --git a/src/DiffEngine/DiffTools_Add.cs b/src/DiffEngine/DiffTools_Add.cs index 35223f1c..23e9568b 100644 --- a/src/DiffEngine/DiffTools_Add.cs +++ b/src/DiffEngine/DiffTools_Add.cs @@ -10,6 +10,7 @@ public static partial class DiffTools bool? requiresTarget = null, bool? useShellExecute = true, bool? createNoWindow = null, + bool? killLockingProcess = null, LaunchArguments? launchArguments = null, string? exePath = null, IEnumerable? binaryExtensions = null) @@ -30,26 +31,27 @@ public static partial class DiffTools launchArguments ?? existing.LaunchArguments, exePath ?? existing.ExePath, binaryExtensions ?? existing.BinaryExtensions, - createNoWindow ?? existing.CreateNoWindow); + createNoWindow ?? existing.CreateNoWindow, + killLockingProcess ?? existing.KillLockingProcess); } public static ResolvedTool? AddTool(string name, bool autoRefresh, bool isMdi, bool supportsText, bool requiresTarget, bool useShellExecute, IEnumerable binaryExtensions, OsSupport osSupport) => AddTool(name, null, autoRefresh, isMdi, supportsText, requiresTarget, binaryExtensions, osSupport, useShellExecute, createNoWindow: false); - public static ResolvedTool? AddTool(string name, bool autoRefresh, bool isMdi, bool supportsText, bool requiresTarget, bool useShellExecute, LaunchArguments launchArguments, string exePath, IEnumerable binaryExtensions, bool createNoWindow = false) => - AddInner(name, null, autoRefresh, isMdi, supportsText, requiresTarget, binaryExtensions, exePath, launchArguments, useShellExecute, createNoWindow); + public static ResolvedTool? AddTool(string name, bool autoRefresh, bool isMdi, bool supportsText, bool requiresTarget, bool useShellExecute, LaunchArguments launchArguments, string exePath, IEnumerable binaryExtensions, bool createNoWindow = false, bool killLockingProcess = false) => + AddInner(name, null, autoRefresh, isMdi, supportsText, requiresTarget, binaryExtensions, exePath, launchArguments, useShellExecute, createNoWindow, killLockingProcess); - static ResolvedTool? AddTool(string name, DiffTool? diffTool, bool autoRefresh, bool isMdi, bool supportsText, bool requiresTarget, IEnumerable binaryExtensions, OsSupport osSupport, bool useShellExecute, bool createNoWindow) + static ResolvedTool? AddTool(string name, DiffTool? diffTool, bool autoRefresh, bool isMdi, bool supportsText, bool requiresTarget, IEnumerable binaryExtensions, OsSupport osSupport, bool useShellExecute, bool createNoWindow, bool killLockingProcess = false) { if (!OsSettingsResolver.Resolve(name, osSupport, out var exePath, out var launchArguments)) { return null; } - return AddInner(name, diffTool, autoRefresh, isMdi, supportsText, requiresTarget, binaryExtensions, exePath, launchArguments, useShellExecute, createNoWindow); + return AddInner(name, diffTool, autoRefresh, isMdi, supportsText, requiresTarget, binaryExtensions, exePath, launchArguments, useShellExecute, createNoWindow, killLockingProcess); } - static ResolvedTool? AddInner(string name, DiffTool? diffTool, bool autoRefresh, bool isMdi, bool supportsText, bool requiresTarget, IEnumerable binaries, string exePath, LaunchArguments launchArguments, bool useShellExecute, bool createNoWindow) + static ResolvedTool? AddInner(string name, DiffTool? diffTool, bool autoRefresh, bool isMdi, bool supportsText, bool requiresTarget, IEnumerable binaries, string exePath, LaunchArguments launchArguments, bool useShellExecute, bool createNoWindow, bool killLockingProcess = false) { Guard.AgainstEmpty(name, nameof(name)); if (resolved.Any(_ => _.Name.Equals(name, StringComparison.OrdinalIgnoreCase))) @@ -73,7 +75,8 @@ public static partial class DiffTools requiresTarget, supportsText, useShellExecute, - createNoWindow); + createNoWindow, + killLockingProcess); AddResolvedToolAtStart(tool); @@ -111,7 +114,8 @@ static void InitTools(bool throwForNoTool, IEnumerable order) definition.BinaryExtensions, definition.OsSupport, definition.UseShellExecute, - definition.CreateNoWindow); + definition.CreateNoWindow, + definition.KillLockingProcess); } custom.Reverse(); diff --git a/src/DiffEngine/Implementation/MsWordDiff.cs b/src/DiffEngine/Implementation/MsWordDiff.cs index 58951630..432b0411 100644 --- a/src/DiffEngine/Implementation/MsWordDiff.cs +++ b/src/DiffEngine/Implementation/MsWordDiff.cs @@ -26,6 +26,7 @@ public static Definition MsWordDiff() @"%USERPROFILE%\.dotnet\tools\")), UseShellExecute: false, CreateNoWindow: true, + KillLockingProcess: true, Notes: """ * Install via `dotnet tool install -g MsWordDiff` * Requires Microsoft Word to be installed diff --git a/src/DiffEngine/ResolvedTool.cs b/src/DiffEngine/ResolvedTool.cs index 535b1d65..86e3092a 100644 --- a/src/DiffEngine/ResolvedTool.cs +++ b/src/DiffEngine/ResolvedTool.cs @@ -23,8 +23,8 @@ public string GetArguments(string tempFile, string targetFile) return LaunchArguments.Right(tempFile, targetFile); } - public ResolvedTool(string name, string exePath, LaunchArguments launchArguments, bool isMdi, bool autoRefresh, IReadOnlyCollection binaryExtensions, bool requiresTarget, bool supportsText, bool useShellExecute, bool createNoWindow = false) : - this(name, null, exePath, launchArguments, isMdi, autoRefresh, binaryExtensions, requiresTarget, supportsText, useShellExecute, createNoWindow) + public ResolvedTool(string name, string exePath, LaunchArguments launchArguments, bool isMdi, bool autoRefresh, IReadOnlyCollection binaryExtensions, bool requiresTarget, bool supportsText, bool useShellExecute, bool createNoWindow = false, bool killLockingProcess = false) : + this(name, null, exePath, launchArguments, isMdi, autoRefresh, binaryExtensions, requiresTarget, supportsText, useShellExecute, createNoWindow, killLockingProcess) { } @@ -39,7 +39,8 @@ public ResolvedTool( bool requiresTarget, bool supportsText, bool useShellExecute, - bool createNoWindow = false) + bool createNoWindow = false, + bool killLockingProcess = false) { Guard.FileExists(exePath, nameof(exePath)); Guard.AgainstEmpty(name, nameof(name)); @@ -63,6 +64,7 @@ Extensions must begin with a period. SupportsText = supportsText; UseShellExecute = useShellExecute; CreateNoWindow = createNoWindow; + KillLockingProcess = killLockingProcess; } public string Name { get; init; } @@ -76,4 +78,5 @@ Extensions must begin with a period. public bool SupportsText { get; init; } public bool UseShellExecute { get; init; } public bool CreateNoWindow { get; init; } + public bool KillLockingProcess { get; init; } } \ No newline at end of file diff --git a/src/DiffEngineTray.Tests/FileLockKillerTest.cs b/src/DiffEngineTray.Tests/FileLockKillerTest.cs new file mode 100644 index 00000000..92a7bc9a --- /dev/null +++ b/src/DiffEngineTray.Tests/FileLockKillerTest.cs @@ -0,0 +1,129 @@ +public class FileLockKillerTest +{ + [Test] + public async Task KillLockingProcesses_WhenFileNotLocked_ReturnsFalse() + { + var file = Path.Combine(Path.GetTempPath(), $"FileLockKillerTest_{Guid.NewGuid()}.txt"); + try + { + File.WriteAllText(file, "content"); + var result = FileLockKiller.KillLockingProcesses(file); + await Assert.That(result).IsFalse(); + } + finally + { + File.Delete(file); + } + } + + [Test] + public async Task KillLockingProcesses_WhenFileDoesNotExist_ReturnsFalse() + { + var result = FileLockKiller.KillLockingProcesses( + Path.Combine(Path.GetTempPath(), $"FileLockKillerTest_{Guid.NewGuid()}.txt")); + await Assert.That(result).IsFalse(); + } + + [Test] + public async Task KillLockingProcesses_WhenFileLocked_KillsProcess() + { + var file = Path.Combine(Path.GetTempPath(), $"FileLockKillerTest_{Guid.NewGuid()}.txt"); + File.WriteAllText(file, "content"); + + var lockProcess = StartFileLockProcess(file); + + try + { + await Assert.That(IsFileLocked(file)).IsTrue(); + + var result = FileLockKiller.KillLockingProcesses(file); + + await Assert.That(result).IsTrue(); + + var exited = lockProcess.WaitForExit(5000); + await Assert.That(exited).IsTrue(); + } + finally + { + if (!lockProcess.HasExited) + { + lockProcess.Kill(); + } + + lockProcess.Dispose(); + File.Delete(file); + } + } + + [Test] + public async Task MoveSucceedsAfterKillingLockingProcess() + { + var file = Path.Combine(Path.GetTempPath(), $"FileLockKillerTest_{Guid.NewGuid()}.txt"); + var tempFile = Path.Combine(Path.GetTempPath(), $"FileLockKillerTest_{Guid.NewGuid()}.txt"); + File.WriteAllText(file, "content"); + File.WriteAllText(tempFile, "new content"); + + var lockProcess = StartFileLockProcess(file); + + try + { + await Assert.That(IsFileLocked(file)).IsTrue(); + await Assert.That(FileEx.SafeMove(tempFile, file)).IsFalse(); + + FileLockKiller.KillLockingProcesses(file); + + await Assert.That(FileEx.SafeMove(tempFile, file)).IsTrue(); + await Assert.That(File.ReadAllText(file)).IsEqualTo("new content"); + } + finally + { + if (!lockProcess.HasExited) + { + lockProcess.Kill(); + } + + lockProcess.Dispose(); + File.Delete(file); + File.Delete(tempFile); + } + } + + static Process StartFileLockProcess(string path) + { + var script = $"$f = [System.IO.File]::Open('{path.Replace("'", "''")}', 'Open', 'ReadWrite', 'None'); [Console]::WriteLine('locked'); Start-Sleep -Seconds 60"; + var process = new Process + { + StartInfo = new() + { + FileName = "powershell.exe", + Arguments = $"-NoProfile -Command \"{script}\"", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true + } + }; + process.Start(); + + // Wait for the process to signal that it has acquired the lock + var line = process.StandardOutput.ReadLine(); + if (line != "locked") + { + throw new InvalidOperationException($"Expected 'locked' but got '{line}'"); + } + + return process; + } + + static bool IsFileLocked(string path) + { + try + { + using var stream = File.Open(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + return false; + } + catch (IOException) + { + return true; + } + } +} diff --git a/src/DiffEngineTray/FileLockKiller.cs b/src/DiffEngineTray/FileLockKiller.cs new file mode 100644 index 00000000..f1f8d86b --- /dev/null +++ b/src/DiffEngineTray/FileLockKiller.cs @@ -0,0 +1,117 @@ +static class FileLockKiller +{ + [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] + static extern int RmStartSession(out uint pSessionHandle, int dwSessionFlags, string strSessionKey); + + [DllImport("rstrtmgr.dll")] + static extern int RmEndSession(uint pSessionHandle); + + [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] + static extern int RmRegisterResources( + uint pSessionHandle, + uint nFiles, + string[] rgsFileNames, + uint nApplications, + [In] RM_UNIQUE_PROCESS[] rgApplications, + uint nServices, + string[] rgsServiceNames); + + [DllImport("rstrtmgr.dll")] + static extern int RmGetList( + uint dwSessionHandle, + out uint pnProcInfoNeeded, + ref uint pnProcInfo, + [In, Out] RM_PROCESS_INFO[]? rgAffectedApps, + ref uint lpdwRebootReasons); + + const int errorMoreData = 234; + + [StructLayout(LayoutKind.Sequential)] + struct RM_UNIQUE_PROCESS + { + public uint dwProcessId; + public System.Runtime.InteropServices.ComTypes.FILETIME ProcessStartTime; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + struct RM_PROCESS_INFO + { + public RM_UNIQUE_PROCESS Process; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] + public string strAppName; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] + public string strServiceShortName; + + public uint ApplicationType; + public uint AppStatus; + public uint TSSessionId; + [MarshalAs(UnmanagedType.Bool)] + public bool bRestartable; + } + + public static bool KillLockingProcesses(string filePath) + { + var killed = false; + + if (RmStartSession(out var sessionHandle, 0, Guid.NewGuid().ToString()) != 0) + { + return false; + } + + try + { + var resources = new[] { filePath }; + if (RmRegisterResources(sessionHandle, (uint)resources.Length, resources, 0, [], 0, []) != 0) + { + return false; + } + + var procInfo = 0u; + var rebootReasons = 0u; + var result = RmGetList(sessionHandle, out var procInfoNeeded, ref procInfo, null, ref rebootReasons); + + if (result != errorMoreData || procInfoNeeded == 0) + { + return false; + } + + var processInfo = new RM_PROCESS_INFO[procInfoNeeded]; + procInfo = procInfoNeeded; + result = RmGetList(sessionHandle, out procInfoNeeded, ref procInfo, processInfo, ref rebootReasons); + + if (result != 0) + { + return false; + } + + for (var i = 0; i < procInfo; i++) + { + var processId = (int)processInfo[i].Process.dwProcessId; + if (!ProcessEx.TryGet(processId, out var process)) + { + continue; + } + + Log.Information( + "Killing locking process '{ProcessName}' (PID: {ProcessId}) for file '{FilePath}'", + processInfo[i].strAppName, + processId, + filePath); + process.KillAndDispose(); + killed = true; + } + } + catch (Exception exception) + { + ExceptionHandler.Handle($"Failed to kill locking processes for '{filePath}'.", exception); + } + finally + { + RmEndSession(sessionHandle); + } + + return killed; + } +} diff --git a/src/DiffEngineTray/TrackedMove.cs b/src/DiffEngineTray/TrackedMove.cs index 28ffd153..9b7e6adf 100644 --- a/src/DiffEngineTray/TrackedMove.cs +++ b/src/DiffEngineTray/TrackedMove.cs @@ -7,7 +7,8 @@ public TrackedMove(string temp, bool canKill, Process? process, string? group, - string extension) + string extension, + bool killLockingProcess = false) { Temp = temp; Target = target; @@ -18,6 +19,7 @@ public TrackedMove(string temp, CanKill = canKill; Process = process; Group = group; + KillLockingProcess = killLockingProcess; } public string Extension { get; } @@ -29,4 +31,5 @@ public TrackedMove(string temp, public bool CanKill { get; } public Process? Process { get; set; } public string? Group { get; } + public bool KillLockingProcess { get; } } \ No newline at end of file diff --git a/src/DiffEngineTray/Tracker.cs b/src/DiffEngineTray/Tracker.cs index fff5f1e0..28c139e3 100644 --- a/src/DiffEngineTray/Tracker.cs +++ b/src/DiffEngineTray/Tracker.cs @@ -156,6 +156,7 @@ static TrackedMove BuildTrackedMove(string temp, string? exe, string? arguments, { var solution = SolutionDirectoryFinder.Find(target); var extension = Path.GetExtension(target).TrimStart('.'); + var killLockingProcess = false; if (exe == null) { if (DiffTools.TryFindByExtension(extension, out var tool)) @@ -163,6 +164,7 @@ static TrackedMove BuildTrackedMove(string temp, string? exe, string? arguments, arguments = tool.GetArguments(temp, target); exe = tool.ExePath; canKill = !tool.IsMdi; + killLockingProcess = tool.KillLockingProcess; } } else if (canKill == null) @@ -170,14 +172,22 @@ static TrackedMove BuildTrackedMove(string temp, string? exe, string? arguments, if (DiffTools.TryFindByPath(exe, out var tool)) { canKill = !tool.IsMdi; + killLockingProcess = tool.KillLockingProcess; } else { canKill = false; } } + else + { + if (DiffTools.TryFindByPath(exe, out var tool)) + { + killLockingProcess = tool.KillLockingProcess; + } + } - return new(temp, target, exe, arguments, canKill.GetValueOrDefault(false), process, solution, extension); + return new(temp, target, exe, arguments, canKill.GetValueOrDefault(false), process, solution, extension, killLockingProcess); } public TrackedDelete AddDelete(string file) => @@ -247,7 +257,18 @@ static void InnerMove(TrackedMove move) if (!FileEx.SafeMove(move.Temp, move.Target)) { - return; + if (move.KillLockingProcess && + FileLockKiller.KillLockingProcesses(move.Target)) + { + if (!FileEx.SafeMove(move.Temp, move.Target)) + { + return; + } + } + else + { + return; + } } var directory = Path.GetDirectoryName(move.Temp)!; diff --git a/src/Directory.Build.props b/src/Directory.Build.props index b91e41a6..b1a59625 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CS1591;CS0649;NU1608;NU1109 - 18.4.2 + 19.0.0 1.0.0 Testing, Snapshot, Diff, Compare Launches diff tools based on file extensions. Designed to be consumed by snapshot testing libraries. From a64bf4c6c2a6db524204e4f80d45d4cfb15fb3c7 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 6 Mar 2026 12:30:44 +1100 Subject: [PATCH 2/2] Update FileLockKiller.cs --- src/DiffEngineTray/FileLockKiller.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/DiffEngineTray/FileLockKiller.cs b/src/DiffEngineTray/FileLockKiller.cs index f1f8d86b..fdf3de6e 100644 --- a/src/DiffEngineTray/FileLockKiller.cs +++ b/src/DiffEngineTray/FileLockKiller.cs @@ -109,7 +109,8 @@ public static bool KillLockingProcesses(string filePath) } finally { - RmEndSession(sessionHandle); + // Nothing useful to do if ending the session fails + _ = RmEndSession(sessionHandle); } return killed;