From 60660cfe353fa1acab67509ce83307f3d9d8b934 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Thu, 5 Feb 2026 19:22:46 +1100 Subject: [PATCH 1/7] Delete stale EmptyFiles marker when output missing Add a new MSBuild target DeleteEmptyFilesMarkerIfTargetMissing that runs before CopyEmptyFilesIncremental and deletes the intermediate marker (EmptyFiles.copied) when the EmptyFiles directory is missing from the target output. This forces the copy to re-run if the output was cleaned without cleaning the intermediate output. Update documentation to clarify that the marker lives in the intermediate output, document the new deletion behavior and the additional re-copy condition, and add a newline at EOF in EmptyFiles.targets. --- buildTransitive/EmptyFileTargets.md | 7 +++++-- buildTransitive/EmptyFiles.targets | 8 +++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/buildTransitive/EmptyFileTargets.md b/buildTransitive/EmptyFileTargets.md index 1bc15c7..85e4fdf 100644 --- a/buildTransitive/EmptyFileTargets.md +++ b/buildTransitive/EmptyFileTargets.md @@ -1,18 +1,21 @@ How it works: 1. Inputs="$(MSBuildThisFileFile)" - Tracks the package file timestamp - 2. Outputs="$(EmptyFilesMarker)" - Tracks a marker file in the output directory + 2. Outputs="$(EmptyFilesMarker)" - Tracks a marker file in the intermediate output directory 3. MSBuild automatically skips the target if all outputs are newer than all inputs 4. SkipUnchangedFiles="true" - Additional optimization to skip individual unchanged files 5. Touch - Updates the marker file timestamp after successful copy + 6. DeleteEmptyFilesMarkerIfTargetMissing - If the EmptyFiles directory is missing from the output, deletes the marker file so the copy re-runs This will only copy files when: 1. The targets is newer than the marker file, OR - 2. The marker file doesn't exist (first build/clean build) + 2. The marker file doesn't exist (first build/clean build), OR + 3. The EmptyFiles directory doesn't exist in the output (marker is deleted to force re-copy) Benefits: * Dramatically reduces IO on incremental builds * Works with dotnet clean (removes marker file) + * Recovers when the output directory is cleaned without cleaning the intermediate directory * Still respects individual file changes via SkipUnchangedFiles diff --git a/buildTransitive/EmptyFiles.targets b/buildTransitive/EmptyFiles.targets index 427bde5..fcefd44 100644 --- a/buildTransitive/EmptyFiles.targets +++ b/buildTransitive/EmptyFiles.targets @@ -4,6 +4,12 @@ $(MSBuildThisFileDirectory)..\files + + + + - \ No newline at end of file + From 9135934caf19ed6277a19f47d6ba1cd420297576 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Thu, 5 Feb 2026 20:03:47 +1100 Subject: [PATCH 2/7] Create BuildTargetsTests.cs --- src/Tests/BuildTargetsTests.cs | 133 +++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/Tests/BuildTargetsTests.cs diff --git a/src/Tests/BuildTargetsTests.cs b/src/Tests/BuildTargetsTests.cs new file mode 100644 index 0000000..d477888 --- /dev/null +++ b/src/Tests/BuildTargetsTests.cs @@ -0,0 +1,133 @@ +using System.Diagnostics; +using VerifyTests; + +public class BuildTargetsTests +{ + [Test] + public async Task BuildCopiesEmptyFiles() + { + using var temp = new TempDirectory(); + + var (nugetSource, packageVersion) = FindPackageInfo(); + WriteCsproj(temp, packageVersion); + WriteNugetConfig(temp, nugetSource); + + var exitCode = await DotnetBuild(temp); + Assert.That(exitCode, Is.EqualTo(0)); + + var emptyFilesDir = Path.Combine(temp.Path, "bin", "Release", "net10.0", "EmptyFiles"); + Assert.That(Directory.Exists(emptyFilesDir), Is.True, "EmptyFiles directory should exist after build"); + Assert.That(Directory.GetFiles(emptyFilesDir, "*", SearchOption.AllDirectories), Is.Not.Empty, "EmptyFiles directory should contain files"); + } + + [Test] + public async Task RecoverFromDeletedEmptyFilesDirectory() + { + using var temp = new TempDirectory(); + + var (nugetSource, packageVersion) = FindPackageInfo(); + WriteCsproj(temp, packageVersion); + WriteNugetConfig(temp, nugetSource); + + // First build + var exitCode = await DotnetBuild(temp); + Assert.That(exitCode, Is.EqualTo(0)); + + var emptyFilesDir = Path.Combine(temp.Path, "bin", "Release", "net10.0", "EmptyFiles"); + Assert.That(Directory.Exists(emptyFilesDir), Is.True, "EmptyFiles directory should exist after first build"); + + // Delete EmptyFiles directory from output (leave obj/ intact so marker file survives) + Directory.Delete(emptyFilesDir, true); + Assert.That(Directory.Exists(emptyFilesDir), Is.False); + + // Second build — should recover + exitCode = await DotnetBuild(temp); + Assert.That(exitCode, Is.EqualTo(0)); + + Assert.That(Directory.Exists(emptyFilesDir), Is.True, "EmptyFiles directory should exist after second build"); + Assert.That(Directory.GetFiles(emptyFilesDir, "*", SearchOption.AllDirectories), Is.Not.Empty, "EmptyFiles directory should contain files after recovery"); + } + + static void WriteCsproj(TempDirectory temp, string version) + { + var csproj = $""" + + + net10.0 + packages + + + + + + """; + File.WriteAllText(Path.Combine(temp.Path, "Project.csproj"), csproj); + } + + static void WriteNugetConfig(TempDirectory temp, string nugetSource) + { + var config = $""" + + + + + + + + """; + File.WriteAllText(Path.Combine(temp.Path, "nuget.config"), config); + } + + static async Task DotnetBuild(TempDirectory temp) + { + var startInfo = new ProcessStartInfo("dotnet", "build --configuration Release --disable-build-servers") + { + WorkingDirectory = temp.Path, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + var process = Process.Start(startInfo)!; + var stdout = await process.StandardOutput.ReadToEndAsync(); + var stderr = await process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + + TestContext.Out.WriteLine("STDOUT:"); + TestContext.Out.WriteLine(stdout); + TestContext.Out.WriteLine("STDERR:"); + TestContext.Out.WriteLine(stderr); + + return process.ExitCode; + } + + static (string nugetSource, string packageVersion) FindPackageInfo() + { + var nugetSource = FindNugetSource(); + var packageVersion = FindPackageVersion(nugetSource); + return (nugetSource, packageVersion); + } + + static string FindNugetSource() + { + var solutionDir = SolutionDirectoryFinder.Find(); + var nugetsDir = Path.GetFullPath(Path.Combine(solutionDir, "..", "nugets")); + if (Directory.Exists(nugetsDir)) + { + return nugetsDir; + } + + throw new InvalidOperationException($"Cannot find nugets directory at {nugetsDir}"); + } + + static string FindPackageVersion(string nugetSource) + { + var nupkg = Directory.GetFiles(nugetSource, "EmptyFiles.*.nupkg") + .Where(_ => !Path.GetFileName(_).StartsWith("EmptyFiles.Tool")) + .FirstOrDefault() ?? throw new InvalidOperationException($"Cannot find EmptyFiles nupkg in {nugetSource}"); + + var fileName = Path.GetFileNameWithoutExtension(nupkg); + return fileName["EmptyFiles.".Length..]; + } +} From 23e604793a87f6909b570d5758b22203b4102d47 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 6 Feb 2026 13:52:59 +1100 Subject: [PATCH 3/7] Update BuildTargetsTests.cs --- src/Tests/BuildTargetsTests.cs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/Tests/BuildTargetsTests.cs b/src/Tests/BuildTargetsTests.cs index d477888..39fbc9d 100644 --- a/src/Tests/BuildTargetsTests.cs +++ b/src/Tests/BuildTargetsTests.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using VerifyTests; public class BuildTargetsTests { @@ -131,3 +130,22 @@ static string FindPackageVersion(string nugetSource) return fileName["EmptyFiles.".Length..]; } } + +sealed class TempDirectory : IDisposable +{ + public string Path { get; } + + public TempDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), System.IO.Path.GetRandomFileName()); + Directory.CreateDirectory(Path); + } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, true); + } + } +} From 8b6a2a305bcc733205c6fab4db17c8397fc25b5c Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 6 Feb 2026 15:12:53 +1100 Subject: [PATCH 4/7] Update BuildTargetsTests.cs --- src/Tests/BuildTargetsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tests/BuildTargetsTests.cs b/src/Tests/BuildTargetsTests.cs index 39fbc9d..759b911 100644 --- a/src/Tests/BuildTargetsTests.cs +++ b/src/Tests/BuildTargetsTests.cs @@ -79,7 +79,7 @@ static void WriteNugetConfig(TempDirectory temp, string nugetSource) static async Task DotnetBuild(TempDirectory temp) { - var startInfo = new ProcessStartInfo("dotnet", "build --configuration Release --disable-build-servers") + var startInfo = new ProcessStartInfo("dotnet", "build --configuration Release --disable-build-servers /nodeReuse:false /p:UseSharedCompilation=false") { WorkingDirectory = temp.Path, RedirectStandardOutput = true, From 69a459125ef35753ef27b1a11085dcce7a5e5672 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 6 Feb 2026 17:41:18 +1100 Subject: [PATCH 5/7] Update BuildTargetsTests.cs --- src/Tests/BuildTargetsTests.cs | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/Tests/BuildTargetsTests.cs b/src/Tests/BuildTargetsTests.cs index 759b911..41cd754 100644 --- a/src/Tests/BuildTargetsTests.cs +++ b/src/Tests/BuildTargetsTests.cs @@ -1,5 +1,3 @@ -using System.Diagnostics; - public class BuildTargetsTests { [Test] @@ -12,11 +10,11 @@ public async Task BuildCopiesEmptyFiles() WriteNugetConfig(temp, nugetSource); var exitCode = await DotnetBuild(temp); - Assert.That(exitCode, Is.EqualTo(0)); + That(exitCode, Is.EqualTo(0)); var emptyFilesDir = Path.Combine(temp.Path, "bin", "Release", "net10.0", "EmptyFiles"); - Assert.That(Directory.Exists(emptyFilesDir), Is.True, "EmptyFiles directory should exist after build"); - Assert.That(Directory.GetFiles(emptyFilesDir, "*", SearchOption.AllDirectories), Is.Not.Empty, "EmptyFiles directory should contain files"); + That(Directory.Exists(emptyFilesDir), Is.True, "EmptyFiles directory should exist after build"); + That(Directory.GetFiles(emptyFilesDir, "*", SearchOption.AllDirectories), Is.Not.Empty, "EmptyFiles directory should contain files"); } [Test] @@ -30,21 +28,21 @@ public async Task RecoverFromDeletedEmptyFilesDirectory() // First build var exitCode = await DotnetBuild(temp); - Assert.That(exitCode, Is.EqualTo(0)); + That(exitCode, Is.EqualTo(0)); var emptyFilesDir = Path.Combine(temp.Path, "bin", "Release", "net10.0", "EmptyFiles"); - Assert.That(Directory.Exists(emptyFilesDir), Is.True, "EmptyFiles directory should exist after first build"); + That(Directory.Exists(emptyFilesDir), Is.True, "EmptyFiles directory should exist after first build"); // Delete EmptyFiles directory from output (leave obj/ intact so marker file survives) Directory.Delete(emptyFilesDir, true); - Assert.That(Directory.Exists(emptyFilesDir), Is.False); + That(Directory.Exists(emptyFilesDir), Is.False); // Second build — should recover exitCode = await DotnetBuild(temp); - Assert.That(exitCode, Is.EqualTo(0)); + That(exitCode, Is.EqualTo(0)); - Assert.That(Directory.Exists(emptyFilesDir), Is.True, "EmptyFiles directory should exist after second build"); - Assert.That(Directory.GetFiles(emptyFilesDir, "*", SearchOption.AllDirectories), Is.Not.Empty, "EmptyFiles directory should contain files after recovery"); + That(Directory.Exists(emptyFilesDir), Is.True, "EmptyFiles directory should exist after second build"); + That(Directory.GetFiles(emptyFilesDir, "*", SearchOption.AllDirectories), Is.Not.Empty, "EmptyFiles directory should contain files after recovery"); } static void WriteCsproj(TempDirectory temp, string version) @@ -79,7 +77,7 @@ static void WriteNugetConfig(TempDirectory temp, string nugetSource) static async Task DotnetBuild(TempDirectory temp) { - var startInfo = new ProcessStartInfo("dotnet", "build --configuration Release --disable-build-servers /nodeReuse:false /p:UseSharedCompilation=false") + var startInfo = new ProcessStartInfo("dotnet", "build --configuration Release --disable-build-servers -nodeReuse:false /p:UseSharedCompilation=false") { WorkingDirectory = temp.Path, RedirectStandardOutput = true, @@ -88,7 +86,7 @@ static async Task DotnetBuild(TempDirectory temp) CreateNoWindow = true, }; - var process = Process.Start(startInfo)!; + using var process = Process.Start(startInfo)!; var stdout = await process.StandardOutput.ReadToEndAsync(); var stderr = await process.StandardError.ReadToEndAsync(); await process.WaitForExitAsync(); @@ -122,9 +120,10 @@ static string FindNugetSource() static string FindPackageVersion(string nugetSource) { - var nupkg = Directory.GetFiles(nugetSource, "EmptyFiles.*.nupkg") - .Where(_ => !Path.GetFileName(_).StartsWith("EmptyFiles.Tool")) - .FirstOrDefault() ?? throw new InvalidOperationException($"Cannot find EmptyFiles nupkg in {nugetSource}"); + var nupkg = Directory + .GetFiles(nugetSource, "EmptyFiles.*.nupkg") + .FirstOrDefault(_ => !Path.GetFileName(_).StartsWith("EmptyFiles.Tool")) ?? + throw new InvalidOperationException($"Cannot find EmptyFiles nupkg in {nugetSource}"); var fileName = Path.GetFileNameWithoutExtension(nupkg); return fileName["EmptyFiles.".Length..]; From b8df80d702bde792cef3c184daa0ebb71e914fb5 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 6 Feb 2026 20:56:03 +1100 Subject: [PATCH 6/7] Log locked files and locking processes on test cleanup failure Uses Windows Restart Manager API to identify which processes hold file locks when TempDirectory.Dispose() fails. Co-Authored-By: Claude Opus 4.5 --- src/Tests/BuildTargetsTests.cs | 151 ++++++++++++++++++++++++++++++++- src/Tests/GlobalUsings.cs | 4 +- 2 files changed, 153 insertions(+), 2 deletions(-) diff --git a/src/Tests/BuildTargetsTests.cs b/src/Tests/BuildTargetsTests.cs index 41cd754..85d6964 100644 --- a/src/Tests/BuildTargetsTests.cs +++ b/src/Tests/BuildTargetsTests.cs @@ -142,9 +142,158 @@ public TempDirectory() public void Dispose() { - if (Directory.Exists(Path)) + if (!Directory.Exists(Path)) + { + return; + } + + try { Directory.Delete(Path, true); } + catch (IOException ex) + { + TestContext.Out.WriteLine($"Failed to delete temp directory: {Path}"); + TestContext.Out.WriteLine($"Exception: {ex.Message}"); + LogLockedFiles(Path); + throw; + } + } + + static void LogLockedFiles(string directory) + { + TestContext.Out.WriteLine("Scanning for locked files..."); + + foreach (var file in Directory.GetFiles(directory, "*", SearchOption.AllDirectories)) + { + if (IsFileLocked(file)) + { + TestContext.Out.WriteLine($"LOCKED: {file}"); + var processes = GetLockingProcesses(file); + foreach (var proc in processes) + { + TestContext.Out.WriteLine($" Locked by: {proc}"); + } + } + } + } + + static bool IsFileLocked(string filePath) + { + try + { + using var stream = new FileStream(filePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + return false; + } + catch (IOException) + { + return true; + } + catch (UnauthorizedAccessException) + { + return true; + } + } + + static List GetLockingProcesses(string filePath) + { + var result = new List(); + + var res = RmStartSession(out var sessionHandle, 0, Guid.NewGuid().ToString()); + if (res != 0) + { + result.Add($"(Failed to start Restart Manager session: error {res})"); + return result; + } + + try + { + string[] resources = [filePath]; + res = RmRegisterResources(sessionHandle, (uint)resources.Length, resources, 0, null, 0, null); + if (res != 0) + { + result.Add($"(Failed to register resource: error {res})"); + return result; + } + + uint procInfoNeeded = 0; + uint procInfo = 0; + uint rebootReasons = 0; + + res = RmGetList(sessionHandle, out procInfoNeeded, ref procInfo, null, ref rebootReasons); + if (res == ERROR_MORE_DATA && procInfoNeeded > 0) + { + var processInfo = new RM_PROCESS_INFO[procInfoNeeded]; + procInfo = procInfoNeeded; + + res = RmGetList(sessionHandle, out procInfoNeeded, ref procInfo, processInfo, ref rebootReasons); + if (res == 0) + { + for (var i = 0; i < procInfo; i++) + { + try + { + var proc = Process.GetProcessById(processInfo[i].Process.dwProcessId); + result.Add($"PID {proc.Id}: {proc.ProcessName} ({proc.MainModule?.FileName ?? "unknown path"})"); + } + catch + { + result.Add($"PID {processInfo[i].Process.dwProcessId}: {processInfo[i].strAppName} (process no longer running or inaccessible)"); + } + } + } + } + else if (res == 0 && procInfoNeeded == 0) + { + result.Add("(No processes found via Restart Manager - file may be locked by system)"); + } + } + finally + { + RmEndSession(sessionHandle); + } + + if (result.Count == 0) + { + result.Add("(Unable to determine locking process)"); + } + + return result; + } + + const int ERROR_MORE_DATA = 234; + + [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, 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); + + [StructLayout(LayoutKind.Sequential)] + struct RM_UNIQUE_PROCESS + { + public int 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 int ApplicationType; + public uint AppStatus; + public uint TSSessionId; + [MarshalAs(UnmanagedType.Bool)] + public bool bRestartable; } } diff --git a/src/Tests/GlobalUsings.cs b/src/Tests/GlobalUsings.cs index f5a79e6..ad8e2a1 100644 --- a/src/Tests/GlobalUsings.cs +++ b/src/Tests/GlobalUsings.cs @@ -1,3 +1,5 @@ global using EmptyFiles; global using NUnit.Framework; -global using System.Collections.Immutable; \ No newline at end of file +global using System.Collections.Immutable; +global using System.Diagnostics; +global using System.Runtime.InteropServices; \ No newline at end of file From 2e1ce976bfe2b2ff318d6f3c6fbe62e5c107a2f5 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 6 Feb 2026 21:33:38 +1100 Subject: [PATCH 7/7] . --- .editorconfig | 1 + src/Directory.Packages.props | 4 +- src/Tests/BuildTargetsTests.cs | 181 +-------------------------- src/Tests/SolutionDirectoryFinder.cs | 38 ------ src/Tests/Tests.cs | 2 +- src/Tests/Tests.csproj | 2 + 6 files changed, 13 insertions(+), 215 deletions(-) delete mode 100644 src/Tests/SolutionDirectoryFinder.cs diff --git a/.editorconfig b/.editorconfig index aed06bb..0e6515c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -343,6 +343,7 @@ resharper_braces_for_for = required resharper_return_value_of_pure_method_is_not_used_highlighting = error +resharper_member_hides_interface_member_with_default_implementation_highlighting = error resharper_misleading_body_like_statement_highlighting = error diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 0d419be..0b65cdd 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -8,12 +8,14 @@ - + + + \ No newline at end of file diff --git a/src/Tests/BuildTargetsTests.cs b/src/Tests/BuildTargetsTests.cs index 85d6964..eeec61e 100644 --- a/src/Tests/BuildTargetsTests.cs +++ b/src/Tests/BuildTargetsTests.cs @@ -91,10 +91,10 @@ static async Task DotnetBuild(TempDirectory temp) var stderr = await process.StandardError.ReadToEndAsync(); await process.WaitForExitAsync(); - TestContext.Out.WriteLine("STDOUT:"); - TestContext.Out.WriteLine(stdout); - TestContext.Out.WriteLine("STDERR:"); - TestContext.Out.WriteLine(stderr); + await TestContext.Out.WriteLineAsync("STDOUT:"); + await TestContext.Out.WriteLineAsync(stdout); + await TestContext.Out.WriteLineAsync("STDERR:"); + await TestContext.Out.WriteLineAsync(stderr); return process.ExitCode; } @@ -108,8 +108,7 @@ static async Task DotnetBuild(TempDirectory temp) static string FindNugetSource() { - var solutionDir = SolutionDirectoryFinder.Find(); - var nugetsDir = Path.GetFullPath(Path.Combine(solutionDir, "..", "nugets")); + var nugetsDir = Path.GetFullPath(Path.Combine(ProjectFiles.SolutionDirectory, "..", "nugets")); if (Directory.Exists(nugetsDir)) { return nugetsDir; @@ -128,172 +127,4 @@ static string FindPackageVersion(string nugetSource) var fileName = Path.GetFileNameWithoutExtension(nupkg); return fileName["EmptyFiles.".Length..]; } -} - -sealed class TempDirectory : IDisposable -{ - public string Path { get; } - - public TempDirectory() - { - Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), System.IO.Path.GetRandomFileName()); - Directory.CreateDirectory(Path); - } - - public void Dispose() - { - if (!Directory.Exists(Path)) - { - return; - } - - try - { - Directory.Delete(Path, true); - } - catch (IOException ex) - { - TestContext.Out.WriteLine($"Failed to delete temp directory: {Path}"); - TestContext.Out.WriteLine($"Exception: {ex.Message}"); - LogLockedFiles(Path); - throw; - } - } - - static void LogLockedFiles(string directory) - { - TestContext.Out.WriteLine("Scanning for locked files..."); - - foreach (var file in Directory.GetFiles(directory, "*", SearchOption.AllDirectories)) - { - if (IsFileLocked(file)) - { - TestContext.Out.WriteLine($"LOCKED: {file}"); - var processes = GetLockingProcesses(file); - foreach (var proc in processes) - { - TestContext.Out.WriteLine($" Locked by: {proc}"); - } - } - } - } - - static bool IsFileLocked(string filePath) - { - try - { - using var stream = new FileStream(filePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); - return false; - } - catch (IOException) - { - return true; - } - catch (UnauthorizedAccessException) - { - return true; - } - } - - static List GetLockingProcesses(string filePath) - { - var result = new List(); - - var res = RmStartSession(out var sessionHandle, 0, Guid.NewGuid().ToString()); - if (res != 0) - { - result.Add($"(Failed to start Restart Manager session: error {res})"); - return result; - } - - try - { - string[] resources = [filePath]; - res = RmRegisterResources(sessionHandle, (uint)resources.Length, resources, 0, null, 0, null); - if (res != 0) - { - result.Add($"(Failed to register resource: error {res})"); - return result; - } - - uint procInfoNeeded = 0; - uint procInfo = 0; - uint rebootReasons = 0; - - res = RmGetList(sessionHandle, out procInfoNeeded, ref procInfo, null, ref rebootReasons); - if (res == ERROR_MORE_DATA && procInfoNeeded > 0) - { - var processInfo = new RM_PROCESS_INFO[procInfoNeeded]; - procInfo = procInfoNeeded; - - res = RmGetList(sessionHandle, out procInfoNeeded, ref procInfo, processInfo, ref rebootReasons); - if (res == 0) - { - for (var i = 0; i < procInfo; i++) - { - try - { - var proc = Process.GetProcessById(processInfo[i].Process.dwProcessId); - result.Add($"PID {proc.Id}: {proc.ProcessName} ({proc.MainModule?.FileName ?? "unknown path"})"); - } - catch - { - result.Add($"PID {processInfo[i].Process.dwProcessId}: {processInfo[i].strAppName} (process no longer running or inaccessible)"); - } - } - } - } - else if (res == 0 && procInfoNeeded == 0) - { - result.Add("(No processes found via Restart Manager - file may be locked by system)"); - } - } - finally - { - RmEndSession(sessionHandle); - } - - if (result.Count == 0) - { - result.Add("(Unable to determine locking process)"); - } - - return result; - } - - const int ERROR_MORE_DATA = 234; - - [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, 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); - - [StructLayout(LayoutKind.Sequential)] - struct RM_UNIQUE_PROCESS - { - public int 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 int ApplicationType; - public uint AppStatus; - public uint TSSessionId; - [MarshalAs(UnmanagedType.Bool)] - public bool bRestartable; - } -} +} \ No newline at end of file diff --git a/src/Tests/SolutionDirectoryFinder.cs b/src/Tests/SolutionDirectoryFinder.cs deleted file mode 100644 index a55bcbc..0000000 --- a/src/Tests/SolutionDirectoryFinder.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -static class SolutionDirectoryFinder -{ - public static string Find([CallerFilePath] string testFile = "") - { - var testDirectory = Path.GetDirectoryName(testFile)!; - if (!TryFind(testDirectory, out var solutionDirectory)) - { - throw new("Could not find solution directory"); - } - - return solutionDirectory; - } - - public static bool TryFind(string testDirectory, [NotNullWhen(true)] out string? path) - { - var currentDirectory = testDirectory; - do - { - if (Directory - .GetFiles(currentDirectory, "*.slnx").Length != 0) - { - path = currentDirectory; - return true; - } - - var parent = Directory.GetParent(currentDirectory); - if (parent == null) - { - path = null; - return false; - } - - currentDirectory = parent.FullName; - } while (true); - } -} \ No newline at end of file diff --git a/src/Tests/Tests.cs b/src/Tests/Tests.cs index 2bb7958..f409451 100644 --- a/src/Tests/Tests.cs +++ b/src/Tests/Tests.cs @@ -213,7 +213,7 @@ public void UseFile() [Test] public async Task WriteExtensions() { - var md = Path.Combine(SolutionDirectoryFinder.Find(), "extensions.include.md"); + var md = Path.Combine(ProjectFiles.SolutionDirectory, "extensions.include.md"); File.Delete(md); await using var writer = File.CreateText(md); await WriteCategory(writer, "Archive", AllFiles.Archives); diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj index 19625b1..2438137 100644 --- a/src/Tests/Tests.csproj +++ b/src/Tests/Tests.csproj @@ -12,6 +12,8 @@ + +