Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ obj/
nugets/
.claude/settings.local.json
nul
/TestResults
1 change: 1 addition & 0 deletions src/DiffEngine/Definition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ public record Definition(
OsSupport OsSupport,
bool UseShellExecute = true,
bool CreateNoWindow = false,
bool KillLockingProcess = false,
string? Notes = null);
20 changes: 12 additions & 8 deletions src/DiffEngine/DiffTools_Add.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>? binaryExtensions = null)
Expand All @@ -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<string> 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<string> 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<string> 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<string> binaryExtensions, OsSupport osSupport, bool useShellExecute, bool createNoWindow)
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)
{
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<string> 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<string> 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)))
Expand All @@ -73,7 +75,8 @@ public static partial class DiffTools
requiresTarget,
supportsText,
useShellExecute,
createNoWindow);
createNoWindow,
killLockingProcess);

AddResolvedToolAtStart(tool);

Expand Down Expand Up @@ -111,7 +114,8 @@ static void InitTools(bool throwForNoTool, IEnumerable<DiffTool> order)
definition.BinaryExtensions,
definition.OsSupport,
definition.UseShellExecute,
definition.CreateNoWindow);
definition.CreateNoWindow,
definition.KillLockingProcess);
}

custom.Reverse();
Expand Down
1 change: 1 addition & 0 deletions src/DiffEngine/Implementation/MsWordDiff.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions src/DiffEngine/ResolvedTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> 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<string> 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)
{
}

Expand All @@ -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));
Expand All @@ -63,6 +64,7 @@ Extensions must begin with a period.
SupportsText = supportsText;
UseShellExecute = useShellExecute;
CreateNoWindow = createNoWindow;
KillLockingProcess = killLockingProcess;
}

public string Name { get; init; }
Expand All @@ -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; }
}
129 changes: 129 additions & 0 deletions src/DiffEngineTray.Tests/FileLockKillerTest.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
118 changes: 118 additions & 0 deletions src/DiffEngineTray/FileLockKiller.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
5 changes: 4 additions & 1 deletion src/DiffEngineTray/TrackedMove.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,6 +19,7 @@ public TrackedMove(string temp,
CanKill = canKill;
Process = process;
Group = group;
KillLockingProcess = killLockingProcess;
}

public string Extension { get; }
Expand All @@ -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; }
}
Loading
Loading