Skip to content

Commit b3ffbb9

Browse files
committed
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.
1 parent 403b301 commit b3ffbb9

File tree

10 files changed

+295
-15
lines changed

10 files changed

+295
-15
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ obj/
99
nugets/
1010
.claude/settings.local.json
1111
nul
12+
/TestResults

src/DiffEngine/Definition.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ public record Definition(
1212
OsSupport OsSupport,
1313
bool UseShellExecute = true,
1414
bool CreateNoWindow = false,
15+
bool KillLockingProcess = false,
1516
string? Notes = null);

src/DiffEngine/DiffTools_Add.cs

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ public static partial class DiffTools
1010
bool? requiresTarget = null,
1111
bool? useShellExecute = true,
1212
bool? createNoWindow = null,
13+
bool? killLockingProcess = null,
1314
LaunchArguments? launchArguments = null,
1415
string? exePath = null,
1516
IEnumerable<string>? binaryExtensions = null)
@@ -30,26 +31,27 @@ public static partial class DiffTools
3031
launchArguments ?? existing.LaunchArguments,
3132
exePath ?? existing.ExePath,
3233
binaryExtensions ?? existing.BinaryExtensions,
33-
createNoWindow ?? existing.CreateNoWindow);
34+
createNoWindow ?? existing.CreateNoWindow,
35+
killLockingProcess ?? existing.KillLockingProcess);
3436
}
3537

3638
public static ResolvedTool? AddTool(string name, bool autoRefresh, bool isMdi, bool supportsText, bool requiresTarget, bool useShellExecute, IEnumerable<string> binaryExtensions, OsSupport osSupport) =>
3739
AddTool(name, null, autoRefresh, isMdi, supportsText, requiresTarget, binaryExtensions, osSupport, useShellExecute, createNoWindow: false);
3840

39-
public static ResolvedTool? AddTool(string name, bool autoRefresh, bool isMdi, bool supportsText, bool requiresTarget, bool useShellExecute, LaunchArguments launchArguments, string exePath, IEnumerable<string> binaryExtensions, bool createNoWindow = false) =>
40-
AddInner(name, null, autoRefresh, isMdi, supportsText, requiresTarget, binaryExtensions, exePath, launchArguments, useShellExecute, createNoWindow);
41+
public static ResolvedTool? AddTool(string name, bool autoRefresh, bool isMdi, bool supportsText, bool requiresTarget, bool useShellExecute, LaunchArguments launchArguments, string exePath, IEnumerable<string> binaryExtensions, bool createNoWindow = false, bool killLockingProcess = false) =>
42+
AddInner(name, null, autoRefresh, isMdi, supportsText, requiresTarget, binaryExtensions, exePath, launchArguments, useShellExecute, createNoWindow, killLockingProcess);
4143

42-
static ResolvedTool? AddTool(string name, DiffTool? diffTool, bool autoRefresh, bool isMdi, bool supportsText, bool requiresTarget, IEnumerable<string> binaryExtensions, OsSupport osSupport, bool useShellExecute, bool createNoWindow)
44+
static ResolvedTool? AddTool(string name, DiffTool? diffTool, bool autoRefresh, bool isMdi, bool supportsText, bool requiresTarget, IEnumerable<string> binaryExtensions, OsSupport osSupport, bool useShellExecute, bool createNoWindow, bool killLockingProcess = false)
4345
{
4446
if (!OsSettingsResolver.Resolve(name, osSupport, out var exePath, out var launchArguments))
4547
{
4648
return null;
4749
}
4850

49-
return AddInner(name, diffTool, autoRefresh, isMdi, supportsText, requiresTarget, binaryExtensions, exePath, launchArguments, useShellExecute, createNoWindow);
51+
return AddInner(name, diffTool, autoRefresh, isMdi, supportsText, requiresTarget, binaryExtensions, exePath, launchArguments, useShellExecute, createNoWindow, killLockingProcess);
5052
}
5153

52-
static ResolvedTool? AddInner(string name, DiffTool? diffTool, bool autoRefresh, bool isMdi, bool supportsText, bool requiresTarget, IEnumerable<string> binaries, string exePath, LaunchArguments launchArguments, bool useShellExecute, bool createNoWindow)
54+
static ResolvedTool? AddInner(string name, DiffTool? diffTool, bool autoRefresh, bool isMdi, bool supportsText, bool requiresTarget, IEnumerable<string> binaries, string exePath, LaunchArguments launchArguments, bool useShellExecute, bool createNoWindow, bool killLockingProcess = false)
5355
{
5456
Guard.AgainstEmpty(name, nameof(name));
5557
if (resolved.Any(_ => _.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
@@ -73,7 +75,8 @@ public static partial class DiffTools
7375
requiresTarget,
7476
supportsText,
7577
useShellExecute,
76-
createNoWindow);
78+
createNoWindow,
79+
killLockingProcess);
7780

7881
AddResolvedToolAtStart(tool);
7982

@@ -111,7 +114,8 @@ static void InitTools(bool throwForNoTool, IEnumerable<DiffTool> order)
111114
definition.BinaryExtensions,
112115
definition.OsSupport,
113116
definition.UseShellExecute,
114-
definition.CreateNoWindow);
117+
definition.CreateNoWindow,
118+
definition.KillLockingProcess);
115119
}
116120

117121
custom.Reverse();

src/DiffEngine/Implementation/MsWordDiff.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public static Definition MsWordDiff()
2626
@"%USERPROFILE%\.dotnet\tools\")),
2727
UseShellExecute: false,
2828
CreateNoWindow: true,
29+
KillLockingProcess: true,
2930
Notes: """
3031
* Install via `dotnet tool install -g MsWordDiff`
3132
* Requires Microsoft Word to be installed

src/DiffEngine/ResolvedTool.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ public string GetArguments(string tempFile, string targetFile)
2323
return LaunchArguments.Right(tempFile, targetFile);
2424
}
2525

26-
public ResolvedTool(string name, string exePath, LaunchArguments launchArguments, bool isMdi, bool autoRefresh, IReadOnlyCollection<string> binaryExtensions, bool requiresTarget, bool supportsText, bool useShellExecute, bool createNoWindow = false) :
27-
this(name, null, exePath, launchArguments, isMdi, autoRefresh, binaryExtensions, requiresTarget, supportsText, useShellExecute, createNoWindow)
26+
public ResolvedTool(string name, string exePath, LaunchArguments launchArguments, bool isMdi, bool autoRefresh, IReadOnlyCollection<string> binaryExtensions, bool requiresTarget, bool supportsText, bool useShellExecute, bool createNoWindow = false, bool killLockingProcess = false) :
27+
this(name, null, exePath, launchArguments, isMdi, autoRefresh, binaryExtensions, requiresTarget, supportsText, useShellExecute, createNoWindow, killLockingProcess)
2828
{
2929
}
3030

@@ -39,7 +39,8 @@ public ResolvedTool(
3939
bool requiresTarget,
4040
bool supportsText,
4141
bool useShellExecute,
42-
bool createNoWindow = false)
42+
bool createNoWindow = false,
43+
bool killLockingProcess = false)
4344
{
4445
Guard.FileExists(exePath, nameof(exePath));
4546
Guard.AgainstEmpty(name, nameof(name));
@@ -63,6 +64,7 @@ Extensions must begin with a period.
6364
SupportsText = supportsText;
6465
UseShellExecute = useShellExecute;
6566
CreateNoWindow = createNoWindow;
67+
KillLockingProcess = killLockingProcess;
6668
}
6769

6870
public string Name { get; init; }
@@ -76,4 +78,5 @@ Extensions must begin with a period.
7678
public bool SupportsText { get; init; }
7779
public bool UseShellExecute { get; init; }
7880
public bool CreateNoWindow { get; init; }
81+
public bool KillLockingProcess { get; init; }
7982
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
public class FileLockKillerTest
2+
{
3+
[Test]
4+
public async Task KillLockingProcesses_WhenFileNotLocked_ReturnsFalse()
5+
{
6+
var file = Path.Combine(Path.GetTempPath(), $"FileLockKillerTest_{Guid.NewGuid()}.txt");
7+
try
8+
{
9+
File.WriteAllText(file, "content");
10+
var result = FileLockKiller.KillLockingProcesses(file);
11+
await Assert.That(result).IsFalse();
12+
}
13+
finally
14+
{
15+
File.Delete(file);
16+
}
17+
}
18+
19+
[Test]
20+
public async Task KillLockingProcesses_WhenFileDoesNotExist_ReturnsFalse()
21+
{
22+
var result = FileLockKiller.KillLockingProcesses(
23+
Path.Combine(Path.GetTempPath(), $"FileLockKillerTest_{Guid.NewGuid()}.txt"));
24+
await Assert.That(result).IsFalse();
25+
}
26+
27+
[Test]
28+
public async Task KillLockingProcesses_WhenFileLocked_KillsProcess()
29+
{
30+
var file = Path.Combine(Path.GetTempPath(), $"FileLockKillerTest_{Guid.NewGuid()}.txt");
31+
File.WriteAllText(file, "content");
32+
33+
var lockProcess = StartFileLockProcess(file);
34+
35+
try
36+
{
37+
await Assert.That(IsFileLocked(file)).IsTrue();
38+
39+
var result = FileLockKiller.KillLockingProcesses(file);
40+
41+
await Assert.That(result).IsTrue();
42+
43+
var exited = lockProcess.WaitForExit(5000);
44+
await Assert.That(exited).IsTrue();
45+
}
46+
finally
47+
{
48+
if (!lockProcess.HasExited)
49+
{
50+
lockProcess.Kill();
51+
}
52+
53+
lockProcess.Dispose();
54+
File.Delete(file);
55+
}
56+
}
57+
58+
[Test]
59+
public async Task MoveSucceedsAfterKillingLockingProcess()
60+
{
61+
var file = Path.Combine(Path.GetTempPath(), $"FileLockKillerTest_{Guid.NewGuid()}.txt");
62+
var tempFile = Path.Combine(Path.GetTempPath(), $"FileLockKillerTest_{Guid.NewGuid()}.txt");
63+
File.WriteAllText(file, "content");
64+
File.WriteAllText(tempFile, "new content");
65+
66+
var lockProcess = StartFileLockProcess(file);
67+
68+
try
69+
{
70+
await Assert.That(IsFileLocked(file)).IsTrue();
71+
await Assert.That(FileEx.SafeMove(tempFile, file)).IsFalse();
72+
73+
FileLockKiller.KillLockingProcesses(file);
74+
75+
await Assert.That(FileEx.SafeMove(tempFile, file)).IsTrue();
76+
await Assert.That(File.ReadAllText(file)).IsEqualTo("new content");
77+
}
78+
finally
79+
{
80+
if (!lockProcess.HasExited)
81+
{
82+
lockProcess.Kill();
83+
}
84+
85+
lockProcess.Dispose();
86+
File.Delete(file);
87+
File.Delete(tempFile);
88+
}
89+
}
90+
91+
static Process StartFileLockProcess(string path)
92+
{
93+
var script = $"$f = [System.IO.File]::Open('{path.Replace("'", "''")}', 'Open', 'ReadWrite', 'None'); [Console]::WriteLine('locked'); Start-Sleep -Seconds 60";
94+
var process = new Process
95+
{
96+
StartInfo = new()
97+
{
98+
FileName = "powershell.exe",
99+
Arguments = $"-NoProfile -Command \"{script}\"",
100+
UseShellExecute = false,
101+
CreateNoWindow = true,
102+
RedirectStandardOutput = true
103+
}
104+
};
105+
process.Start();
106+
107+
// Wait for the process to signal that it has acquired the lock
108+
var line = process.StandardOutput.ReadLine();
109+
if (line != "locked")
110+
{
111+
throw new InvalidOperationException($"Expected 'locked' but got '{line}'");
112+
}
113+
114+
return process;
115+
}
116+
117+
static bool IsFileLocked(string path)
118+
{
119+
try
120+
{
121+
using var stream = File.Open(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
122+
return false;
123+
}
124+
catch (IOException)
125+
{
126+
return true;
127+
}
128+
}
129+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
static class FileLockKiller
2+
{
3+
[DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)]
4+
static extern int RmStartSession(out uint pSessionHandle, int dwSessionFlags, string strSessionKey);
5+
6+
[DllImport("rstrtmgr.dll")]
7+
static extern int RmEndSession(uint pSessionHandle);
8+
9+
[DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)]
10+
static extern int RmRegisterResources(
11+
uint pSessionHandle,
12+
uint nFiles,
13+
string[] rgsFileNames,
14+
uint nApplications,
15+
[In] RM_UNIQUE_PROCESS[] rgApplications,
16+
uint nServices,
17+
string[] rgsServiceNames);
18+
19+
[DllImport("rstrtmgr.dll")]
20+
static extern int RmGetList(
21+
uint dwSessionHandle,
22+
out uint pnProcInfoNeeded,
23+
ref uint pnProcInfo,
24+
[In, Out] RM_PROCESS_INFO[]? rgAffectedApps,
25+
ref uint lpdwRebootReasons);
26+
27+
const int errorMoreData = 234;
28+
29+
[StructLayout(LayoutKind.Sequential)]
30+
struct RM_UNIQUE_PROCESS
31+
{
32+
public uint dwProcessId;
33+
public System.Runtime.InteropServices.ComTypes.FILETIME ProcessStartTime;
34+
}
35+
36+
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
37+
struct RM_PROCESS_INFO
38+
{
39+
public RM_UNIQUE_PROCESS Process;
40+
41+
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
42+
public string strAppName;
43+
44+
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
45+
public string strServiceShortName;
46+
47+
public uint ApplicationType;
48+
public uint AppStatus;
49+
public uint TSSessionId;
50+
[MarshalAs(UnmanagedType.Bool)]
51+
public bool bRestartable;
52+
}
53+
54+
public static bool KillLockingProcesses(string filePath)
55+
{
56+
var killed = false;
57+
58+
if (RmStartSession(out var sessionHandle, 0, Guid.NewGuid().ToString()) != 0)
59+
{
60+
return false;
61+
}
62+
63+
try
64+
{
65+
var resources = new[] { filePath };
66+
if (RmRegisterResources(sessionHandle, (uint)resources.Length, resources, 0, [], 0, []) != 0)
67+
{
68+
return false;
69+
}
70+
71+
var procInfo = 0u;
72+
var rebootReasons = 0u;
73+
var result = RmGetList(sessionHandle, out var procInfoNeeded, ref procInfo, null, ref rebootReasons);
74+
75+
if (result != errorMoreData || procInfoNeeded == 0)
76+
{
77+
return false;
78+
}
79+
80+
var processInfo = new RM_PROCESS_INFO[procInfoNeeded];
81+
procInfo = procInfoNeeded;
82+
result = RmGetList(sessionHandle, out procInfoNeeded, ref procInfo, processInfo, ref rebootReasons);
83+
84+
if (result != 0)
85+
{
86+
return false;
87+
}
88+
89+
for (var i = 0; i < procInfo; i++)
90+
{
91+
var processId = (int)processInfo[i].Process.dwProcessId;
92+
if (!ProcessEx.TryGet(processId, out var process))
93+
{
94+
continue;
95+
}
96+
97+
Log.Information(
98+
"Killing locking process '{ProcessName}' (PID: {ProcessId}) for file '{FilePath}'",
99+
processInfo[i].strAppName,
100+
processId,
101+
filePath);
102+
process.KillAndDispose();
103+
killed = true;
104+
}
105+
}
106+
catch (Exception exception)
107+
{
108+
ExceptionHandler.Handle($"Failed to kill locking processes for '{filePath}'.", exception);
109+
}
110+
finally
111+
{
112+
RmEndSession(sessionHandle);
113+
}
114+
115+
return killed;
116+
}
117+
}

src/DiffEngineTray/TrackedMove.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ public TrackedMove(string temp,
77
bool canKill,
88
Process? process,
99
string? group,
10-
string extension)
10+
string extension,
11+
bool killLockingProcess = false)
1112
{
1213
Temp = temp;
1314
Target = target;
@@ -18,6 +19,7 @@ public TrackedMove(string temp,
1819
CanKill = canKill;
1920
Process = process;
2021
Group = group;
22+
KillLockingProcess = killLockingProcess;
2123
}
2224

2325
public string Extension { get; }
@@ -29,4 +31,5 @@ public TrackedMove(string temp,
2931
public bool CanKill { get; }
3032
public Process? Process { get; set; }
3133
public string? Group { get; }
34+
public bool KillLockingProcess { get; }
3235
}

0 commit comments

Comments
 (0)