From 55bbc26684d981b60fe654848b83bf4dabd7c43b Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Wed, 13 May 2026 09:41:48 +1000 Subject: [PATCH] Kill process locking file --- docs/kill-process-locking-file.md | 29 ++++ docs/mdsource/doc-index.include.md | 1 + .../kill-process-locking-file.source.md | 22 +++ docs/readme.md | 1 + readme.md | 1 + ...seEnvironmentVariable_failure.verified.txt | 7 + src/Verify.Tests/FileLockKillerTests.cs | 14 ++ src/Verify/Compare/FileComparer.cs | 2 +- src/Verify/FileLockKiller.cs | 49 +++++++ src/Verify/IoHelpers.cs | 29 +++- src/Verify/RestartManager.cs | 134 ++++++++++++++++++ 11 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 docs/kill-process-locking-file.md create mode 100644 docs/mdsource/kill-process-locking-file.source.md create mode 100644 src/Verify.Tests/FileLockKillerTests.ParseEnvironmentVariable_failure.verified.txt create mode 100644 src/Verify.Tests/FileLockKillerTests.cs create mode 100644 src/Verify/FileLockKiller.cs create mode 100644 src/Verify/RestartManager.cs diff --git a/docs/kill-process-locking-file.md b/docs/kill-process-locking-file.md new file mode 100644 index 0000000000..99548161d8 --- /dev/null +++ b/docs/kill-process-locking-file.md @@ -0,0 +1,29 @@ + + +# Kill process locking file + +When a verification writes a `.received.*` file, the operation can fail if another process (for example, an editor or a previously launched diff tool) is holding a lock on that file. The exception surfaces as an `IOException`: "The process cannot access the file because it is being used by another process". + +Verify can detect those processes via the Windows Restart Manager and terminate them so the write can be retried. + + +## Enable + +Set the `Verify_KillProcessLockingFile` environment variable to `true`. + +When enabled, on Windows, any `IOException` raised during a snapshot file write triggers a lookup of every process holding a handle to the target path. Each such process is killed and the write is retried once. + + +## Platform support + +The feature only runs on Windows, since the Restart Manager API is Windows-only. On other platforms, the environment variable has no effect. + + +## Caveats + +Killing processes is destructive. Enable this only in environments where losing the work in those processes is acceptable, such as CI machines or developer machines where the only processes that would lock these files are short-lived diff or editor processes. diff --git a/docs/mdsource/doc-index.include.md b/docs/mdsource/doc-index.include.md index 8b478b0afe..c006baaf5c 100644 --- a/docs/mdsource/doc-index.include.md +++ b/docs/mdsource/doc-index.include.md @@ -37,6 +37,7 @@ * [Verifying binary data](/docs/binary.md) * [Exception Message Format](/docs/exception-message-format.md) * [Build server](/docs/build-server.md) + * [Kill process locking file](/docs/kill-process-locking-file.md) * [Comparers](/docs/comparer.md) * [Converters](/docs/converter.md) * [Recording](/docs/recording.md) diff --git a/docs/mdsource/kill-process-locking-file.source.md b/docs/mdsource/kill-process-locking-file.source.md new file mode 100644 index 0000000000..05edb6bcdf --- /dev/null +++ b/docs/mdsource/kill-process-locking-file.source.md @@ -0,0 +1,22 @@ +# Kill process locking file + +When a verification writes a `.received.*` file, the operation can fail if another process (for example, an editor or a previously launched diff tool) is holding a lock on that file. The exception surfaces as an `IOException`: "The process cannot access the file because it is being used by another process". + +Verify can detect those processes via the Windows Restart Manager and terminate them so the write can be retried. + + +## Enable + +Set the `Verify_KillProcessLockingFile` environment variable to `true`. + +When enabled, on Windows, any `IOException` raised during a snapshot file write triggers a lookup of every process holding a handle to the target path. Each such process is killed and the write is retried once. + + +## Platform support + +The feature only runs on Windows, since the Restart Manager API is Windows-only. On other platforms, the environment variable has no effect. + + +## Caveats + +Killing processes is destructive. Enable this only in environments where losing the work in those processes is acceptable, such as CI machines or developer machines where the only processes that would lock these files are short-lived diff or editor processes. diff --git a/docs/readme.md b/docs/readme.md index f6396121f2..e03dd6fc0b 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -46,6 +46,7 @@ To change this file edit the source file and then run MarkdownSnippets. * [Verifying binary data](/docs/binary.md) * [Exception Message Format](/docs/exception-message-format.md) * [Build server](/docs/build-server.md) + * [Kill process locking file](/docs/kill-process-locking-file.md) * [Comparers](/docs/comparer.md) * [Converters](/docs/converter.md) * [Recording](/docs/recording.md) diff --git a/readme.md b/readme.md index 05c14f8e13..fc87ea0ed8 100644 --- a/readme.md +++ b/readme.md @@ -1072,6 +1072,7 @@ Browser testing via * [Verifying binary data](/docs/binary.md) * [Exception Message Format](/docs/exception-message-format.md) * [Build server](/docs/build-server.md) + * [Kill process locking file](/docs/kill-process-locking-file.md) * [Comparers](/docs/comparer.md) * [Converters](/docs/converter.md) * [Recording](/docs/recording.md) diff --git a/src/Verify.Tests/FileLockKillerTests.ParseEnvironmentVariable_failure.verified.txt b/src/Verify.Tests/FileLockKillerTests.ParseEnvironmentVariable_failure.verified.txt new file mode 100644 index 0000000000..27bba6972e --- /dev/null +++ b/src/Verify.Tests/FileLockKillerTests.ParseEnvironmentVariable_failure.verified.txt @@ -0,0 +1,7 @@ +{ + Type: Exception, + Message: Could not convert `Verify_KillProcessLockingFile` environment variable to a bool. Value: foo, + StackTrace: +at FileLockKiller.ParseEnvironmentVariable(String text) +at FileLockKillerTests.<>c.b__1_0() +} \ No newline at end of file diff --git a/src/Verify.Tests/FileLockKillerTests.cs b/src/Verify.Tests/FileLockKillerTests.cs new file mode 100644 index 0000000000..0a983f0345 --- /dev/null +++ b/src/Verify.Tests/FileLockKillerTests.cs @@ -0,0 +1,14 @@ +public class FileLockKillerTests +{ + [Fact] + public void ParseEnvironmentVariable() + { + Assert.False(FileLockKiller.ParseEnvironmentVariable(null)); + Assert.False(FileLockKiller.ParseEnvironmentVariable("false")); + Assert.True(FileLockKiller.ParseEnvironmentVariable("true")); + } + + [Fact] + public Task ParseEnvironmentVariable_failure() => + Throws(() => FileLockKiller.ParseEnvironmentVariable("foo")); +} diff --git a/src/Verify/Compare/FileComparer.cs b/src/Verify/Compare/FileComparer.cs index 834be24336..897585db33 100644 --- a/src/Verify/Compare/FileComparer.cs +++ b/src/Verify/Compare/FileComparer.cs @@ -47,7 +47,7 @@ static async Task InnerCompare(FilePair file, Stream receivedStr return new(Equality.Equal, compareResult.Message, null, null); } - File.Copy(fileStream.Name, file.ReceivedPath, true); + IoHelpers.CopyFile(fileStream.Name, file.ReceivedPath); return new(Equality.NotEqual, compareResult.Message, null, null); } diff --git a/src/Verify/FileLockKiller.cs b/src/Verify/FileLockKiller.cs new file mode 100644 index 0000000000..a30af8480e --- /dev/null +++ b/src/Verify/FileLockKiller.cs @@ -0,0 +1,49 @@ +static class FileLockKiller +{ + static FileLockKiller() + { + var text = Environment.GetEnvironmentVariable("Verify_KillProcessLockingFile"); + Enabled = ParseEnvironmentVariable(text); + } + + public static bool Enabled { get; } + + public static bool ParseEnvironmentVariable(string? text) + { + if (text is null) + { + return false; + } + + if (bool.TryParse(text, out var result)) + { + return result; + } + + throw new($"Could not convert `Verify_KillProcessLockingFile` environment variable to a bool. Value: {text}"); + } + + public static void KillProcessesLockingFile(string path) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + foreach (var process in RestartManager.GetProcessesLockingFile(path)) + { + try + { + process.Kill(); + process.WaitForExit(5000); + } + catch + { + } + finally + { + process.Dispose(); + } + } + } +} diff --git a/src/Verify/IoHelpers.cs b/src/Verify/IoHelpers.cs index 540bdee621..5d314e908a 100644 --- a/src/Verify/IoHelpers.cs +++ b/src/Verify/IoHelpers.cs @@ -117,8 +117,31 @@ public static Task DisposeAsyncEx(this Stream stream) } #endif - static FileStream OpenWrite(string path) => - new(path, FileMode.Create, FileAccess.Write, FileShare.Read, bufferSize: 4096, useAsync: true); + static FileStream OpenWrite(string path) + { + try + { + return new(path, FileMode.Create, FileAccess.Write, FileShare.Read, bufferSize: 4096, useAsync: true); + } + catch (IOException) when (FileLockKiller.Enabled) + { + FileLockKiller.KillProcessesLockingFile(path); + return new(path, FileMode.Create, FileAccess.Write, FileShare.Read, bufferSize: 4096, useAsync: true); + } + } + + public static void CopyFile(string source, string destination) + { + try + { + File.Copy(source, destination, true); + } + catch (IOException) when (FileLockKiller.Enabled) + { + FileLockKiller.KillProcessesLockingFile(destination); + File.Copy(source, destination, true); + } + } public static FileStream OpenRead(string path) => new(path, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096, useAsync: true); @@ -224,7 +247,7 @@ public static async Task WriteStream(string path, Stream stream) throw new($"Empty data is not allowed. Path: {fileStream.Name}"); } - File.Copy(fileStream.Name, path, true); + CopyFile(fileStream.Name, path); return; } diff --git a/src/Verify/RestartManager.cs b/src/Verify/RestartManager.cs new file mode 100644 index 0000000000..7ade2e33e3 --- /dev/null +++ b/src/Verify/RestartManager.cs @@ -0,0 +1,134 @@ +[SupportedOSPlatform("windows")] +static class RestartManager +{ + const int RmRebootReasonNone = 0; + const int CchRmMaxAppName = 255; + const int CchRmMaxSvcName = 63; + const int ErrorMoreData = 234; + + [StructLayout(LayoutKind.Sequential)] + struct RM_UNIQUE_PROCESS + { + public int dwProcessId; + public System.Runtime.InteropServices.ComTypes.FILETIME ProcessStartTime; + } + + enum RM_APP_TYPE + { + RmUnknownApp = 0, + RmMainWindow = 1, + RmOtherWindow = 2, + RmService = 3, + RmExplorer = 4, + RmConsole = 5, + RmCritical = 1000 + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + struct RM_PROCESS_INFO + { + public RM_UNIQUE_PROCESS Process; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = CchRmMaxAppName + 1)] + public string strAppName; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = CchRmMaxSvcName + 1)] + public string strServiceShortName; + + public RM_APP_TYPE ApplicationType; + public uint AppStatus; + public uint TSSessionId; + + [MarshalAs(UnmanagedType.Bool)] + public bool bRestartable; + } + + [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", CharSet = CharSet.Auto)] + static extern int RmStartSession( + out uint pSessionHandle, + int dwSessionFlags, + string strSessionKey); + + [DllImport("rstrtmgr.dll")] + static extern int RmEndSession(uint pSessionHandle); + + [DllImport("rstrtmgr.dll")] + static extern int RmGetList( + uint dwSessionHandle, + out uint pnProcInfoNeeded, + ref uint pnProcInfo, + [In, Out] RM_PROCESS_INFO[]? rgAffectedApps, + ref uint lpdwRebootReasons); + + public static List GetProcessesLockingFile(string path) + { + var processes = new List(); + var key = Guid.NewGuid().ToString(); + + var startResult = RmStartSession(out var handle, 0, key); + if (startResult != 0) + { + return processes; + } + + try + { + string[] resources = [path]; + var registerResult = RmRegisterResources(handle, (uint) resources.Length, resources, 0, null, 0, null); + if (registerResult != 0) + { + return processes; + } + + uint pnProcInfo = 0; + var rebootReasons = (uint) RmRebootReasonNone; + + var listResult = RmGetList(handle, out var pnProcInfoNeeded, ref pnProcInfo, null, ref rebootReasons); + if (listResult == 0) + { + return processes; + } + + if (listResult != ErrorMoreData) + { + return processes; + } + + var processInfo = new RM_PROCESS_INFO[pnProcInfoNeeded]; + pnProcInfo = pnProcInfoNeeded; + listResult = RmGetList(handle, out pnProcInfoNeeded, ref pnProcInfo, processInfo, ref rebootReasons); + if (listResult != 0) + { + return processes; + } + + for (var i = 0; i < pnProcInfo; i++) + { + try + { + var process = Process.GetProcessById(processInfo[i].Process.dwProcessId); + processes.Add(process); + } + catch (ArgumentException) + { + } + } + } + finally + { + RmEndSession(handle); + } + + return processes; + } +}