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..fdf3de6e --- /dev/null +++ b/src/DiffEngineTray/FileLockKiller.cs @@ -0,0 +1,118 @@ +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 + { + // Nothing useful to do if ending the session fails + _ = 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.