Skip to content

Commit

Permalink
Add more unit tests (#22)
Browse files Browse the repository at this point in the history
  • Loading branch information
cklutz authored Oct 16, 2024
1 parent 7569f2d commit b605de8
Show file tree
Hide file tree
Showing 25 changed files with 829 additions and 156 deletions.
13 changes: 10 additions & 3 deletions src/LockCheck/ExceptionUtils.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
using System;
using System;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using static LockCheck.Windows.NativeMethods;
#if NETFRAMEWORK
using System.Reflection;
#endif
Expand Down Expand Up @@ -74,6 +75,9 @@ public static bool IsFileLocked(this IOException exception)
/// </returns>
public static bool RethrowWithLockingInformation(this Exception ex, string fileName, LockManagerFeatures features = default)
{
if (fileName == null)
throw new ArgumentNullException(nameof(fileName));

return RethrowWithLockingInformation(ex, [fileName], features);
}

Expand Down Expand Up @@ -111,7 +115,10 @@ public static bool RethrowWithLockingInformation(this Exception ex, string fileN
/// </returns>
public static bool RethrowWithLockingInformation(this Exception ex, string[] fileNames, LockManagerFeatures features = default, int? maxProcesses = null)
{
if (fileNames?.Length > 0)
if (fileNames == null)
throw new ArgumentNullException(nameof(fileNames));

if (fileNames.Length > 0)
{
if (ex is IOException ioEx && ioEx.IsFileLocked())
{
Expand Down Expand Up @@ -143,4 +150,4 @@ public static bool RethrowWithLockingInformation(this Exception ex, string[] fil
return false;
}
}
}
}
20 changes: 18 additions & 2 deletions src/LockCheck/Linux/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,28 @@ namespace LockCheck.Linux
{
internal static class Extensions
{
public static bool IsFileLocked(IOException exception)
public static bool IsFileLocked(Exception exception)
{
if (exception == null)
throw new ArgumentNullException(nameof(exception));

return exception.HResult == NativeMethods.EWOULDBLOCK;
// Unix is... complicated. For EACCES, the runtime does throw a UnauthorizedAccessException,
// with potentially an inner exception of type IOException.

if (exception is UnauthorizedAccessException ua &&
ua.InnerException is IOException ioException)
{
return ioException.HResult == NativeMethods.EACCES;
}

// EWOULDBLOCK is directly thrown as an IOException with the respective code.

if (exception is IOException ioException2)
{
return ioException2.HResult == NativeMethods.EWOULDBLOCK;
}

return false;
}
}
}
17 changes: 14 additions & 3 deletions src/LockCheck/Linux/LockInfo.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.IO;

namespace LockCheck.Linux
Expand All @@ -9,11 +9,15 @@ internal class LockInfo
public string LockMode { get; private set; }
public string LockAccess { get; private set; }
public int ProcessId { get; private set; }
public InodeInfo InodeInfo { get; set; }
public InodeInfo InodeInfo { get; private set; }

public static LockInfo ParseLine(string line)
{
// Each line has 8 (sometimes 9, see '->') blocks/fields:
// Each line has 8 (or 9 if you count the "->" marker) fields separated by spaces.
// Additional fields after that are possible, but can be ignored.
// The values for the LockType, LockMode, and LockAccess fields are manifold.
// So we don't interpret them, but just store them as strings. They are only
// provided as informational values anyway and not used for program logic.
//
// 1: POSIX ADVISORY READ 5433 08:01:7864448 128 128
// 2: FLOCK ADVISORY WRITE 2001 08:01:7864554 0 EOF
Expand All @@ -24,6 +28,13 @@ public static LockInfo ParseLine(string line)
// 6: POSIX ADVISORY READ 3548 08:01:7867240 1 1
// 7: POSIX ADVISORY READ 3548 08:01:7865567 1826 2335
// 8: OFDLCK ADVISORY WRITE -1 08:01:8713209 128 191
//
// The major:minor device numbers (e.g. 08:01:...) might not be actual inode numbers
// in case the respective FS is not a physical one (e.g. not ext4, but tempfs or procfs).
// https://utcc.utoronto.ca/~cks/space/blog/linux/ProcLocksNotes has a nice explanation.
// In our case we don't care, because calling code looks up "in reverse" anyway. That is,
// it has an inode for an actual file/directory and needs this information to see if it
// is "locked".

var span = line.AsSpan();
int count = span.Count(' ') + 1;
Expand Down
3 changes: 2 additions & 1 deletion src/LockCheck/Linux/NativeMethods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ namespace LockCheck.Linux
internal static partial class NativeMethods
{
public const int EAGAIN = 11; // Resource unavailable, try again (same value as EWOULDBLOCK),
public const int EACCES = 13; // Mandatory lock
public const int EWOULDBLOCK = EAGAIN; // Operation would block.
public const int ERANGE = 34;

Expand Down Expand Up @@ -135,4 +136,4 @@ internal unsafe struct Passwd

// ---END-----------------------------------------------------------------------------------
}
}
}
63 changes: 62 additions & 1 deletion src/LockCheck/Linux/ProcFileSystem.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
using System;
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Text;

namespace LockCheck.Linux
{
Expand Down Expand Up @@ -103,5 +106,63 @@ private static Dictionary<long, string> GetInodeToPaths(HashSet<string> paths)

return inodesToPaths;
}

internal static string GetProcessExecutablePathFromCmdLine(int processId)
{
byte[] rentedBuffer = null;
try
{
using (var file = new FileStream($"/proc/{processId}/cmdline", FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 1, useAsync: false))
{
Span<byte> buffer = stackalloc byte[256];
int bytesRead = 0;
while (true)
{
if (bytesRead == buffer.Length)
{
// Resize buffer
uint newLength = (uint)buffer.Length * 2;
// Store what was read into new buffer
byte[] tmp = ArrayPool<byte>.Shared.Rent((int)newLength);
buffer.CopyTo(tmp);
// Remember current "rented" buffer (might be null)
byte[] lastRentedBuffer = rentedBuffer;
// From now on, we did rent a buffer. And it will be used for further reads.
buffer = tmp;
rentedBuffer = tmp;
// Return previously rented buffer, if any.
if (lastRentedBuffer != null)
{
ArrayPool<byte>.Shared.Return(lastRentedBuffer);
}
}

Debug.Assert(bytesRead < buffer.Length);
int n = file.Read(buffer.Slice(bytesRead));
bytesRead += n;

// "/proc/<pid>/cmdline" contains the original argument vector (argv), where each part is separated by a null byte.
// See if we have read enough for argv[0], which contains the process name.
ReadOnlySpan<byte> argRemainder = buffer.Slice(0, bytesRead);
int argEnd = argRemainder.IndexOf((byte)'\0');
if (argEnd != -1)
{
return Encoding.UTF8.GetString(argRemainder.Slice(0, argEnd));
}
}
}
}
catch (IOException)
{
return null;
}
finally
{
if (rentedBuffer != null)
{
ArrayPool<byte>.Shared.Return(rentedBuffer);
}
}
}
}
}
10 changes: 5 additions & 5 deletions src/LockCheck/Linux/ProcessInfo.Linux.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Diagnostics;
using System.IO;

Expand Down Expand Up @@ -48,17 +48,17 @@ public static ProcessInfoLinux Create(LockInfo li)
ApplicationName = process.ProcessName
};

// MainModule may be null, if no permissions, etc.
// Note: alternative of "readlink -f /proc/<pid>/exe" will
// also yield results in this case.
if (process.MainModule != null)
{
result.ExecutableFullPath = process.MainModule.FileName;
result.ExecutableName = Path.GetFileName(result.ExecutableFullPath);
}
else
{
result.ExecutableFullPath = process.ProcessName;
// MainModule may be null, if no permissions, etc.
// Using "readlink -f /proc/<pid>/exe" will also yield no results in this case.
// However, "/proc/<pid>/cmdline" can work. So attempt to get the executable name from there.
result.ExecutableFullPath = ProcFileSystem.GetProcessExecutablePathFromCmdLine(li.ProcessId) ?? process.ProcessName;
result.ExecutableName = process.ProcessName;
}
}
Expand Down
8 changes: 7 additions & 1 deletion src/LockCheck/LockCheck.csproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>net8.0;net481</TargetFrameworks>
Expand Down Expand Up @@ -32,6 +32,12 @@
<Compile Remove="Linux/**/*.cs"/>
</ItemGroup>

<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>LockCheck.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>

<ItemGroup>
<PackageReference Include="System.Security.Principal.Windows" Version="5.0.0" />
<PackageReference Include="Nerdbank.GitVersioning" Version="3.6.143" PrivateAssets="all" />
Expand Down
39 changes: 15 additions & 24 deletions src/LockCheck/ProcessInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,13 @@ protected ProcessInfo(int processId, DateTime startTime)

public override int GetHashCode()
{
#if NET
return HashCode.Combine(ProcessId, StartTime);
#else
int h1 = ProcessId.GetHashCode();
int h2 = StartTime.GetHashCode();
return ((h1 << 5) + h1) ^ h2;
#endif
}

public override bool Equals(object obj)
Expand All @@ -91,41 +95,28 @@ public override bool Equals(object obj)
return false;
}

public override string ToString() => ProcessId + "@" + StartTime.ToString("s");
public override string ToString() => ProcessId + "@" + StartTime.ToString("O");

public string ToString(string format)
{
if (format == null)
{
return ToString();
}

if (format == "F")
{
return ToString() + "/" + ApplicationName;
}
string baseFormat = ToString();

if (format == "A")
if (format != null)
{
var sb = new StringBuilder();
sb.Append(nameof(ProcessId)).Append(": ").Append(ProcessId).AppendLine();
sb.Append(nameof(StartTime)).Append(": ").Append(StartTime).AppendLine();
sb.Append(nameof(ExecutableName)).Append(": ").Append(ExecutableName).AppendLine();
sb.Append(nameof(ApplicationName)).Append(": ").Append(ApplicationName).AppendLine();
sb.Append(nameof(Owner)).Append(": ").Append(Owner).AppendLine();
sb.Append(nameof(ExecutableFullPath)).Append(": ").Append(ExecutableFullPath).AppendLine();
sb.Append(nameof(SessionId)).Append(": ").Append(SessionId).AppendLine();
sb.Append(nameof(LockType)).Append(": ").Append(LockType).AppendLine();
sb.Append(nameof(LockMode)).Append(": ").Append(LockMode).AppendLine();
sb.Append(nameof(LockAccess)).Append(": ").Append(LockAccess).AppendLine();
return sb.ToString();
if (format == "F")
{
return $"{baseFormat}/{ApplicationName}";
}
}

return ToString();
return baseFormat;
}

public static void Format(StringBuilder sb, IEnumerable<ProcessInfo> lockers, IEnumerable<string> fileNames, int? maxProcesses = null, string ownerOverwrite = null)
{
if (fileNames == null)
throw new ArgumentNullException(nameof(fileNames));

if (lockers == null || !lockers.Any())
return;

Expand Down
32 changes: 21 additions & 11 deletions src/LockCheck/Windows/Extensions.cs
Original file line number Diff line number Diff line change
@@ -1,26 +1,36 @@
using System;
using System;
using System.IO;

namespace LockCheck.Windows
{
internal static class Extensions
{
public static bool IsFileLocked(IOException exception)
public static bool IsFileLocked(Exception exception)
{
if (exception == null)
throw new ArgumentNullException(nameof(exception));

// Generally it is not safe / stable to convert HRESULTs to Win32 error codes. It works here,
// because we exactly know where we're at. So resist refactoring the following code into an
// (maybe even externally visible) method.
int errorCode = exception.HResult & ((1 << 16) - 1);

if (errorCode == NativeMethods.ERROR_LOCK_VIOLATION ||
errorCode == NativeMethods.ERROR_SHARING_VIOLATION)
if (exception is IOException ioException)
{
return true;
}
// Generally it is not safe / stable to convert HRESULTs to Win32 error codes. It works here,
// because we exactly know where we're at. So resist refactoring the following code into an
// (maybe even externally visible) method.
int errorCode = ioException.HResult & ((1 << 16) - 1);

// Code coverage note: causing a ERROR_LOCK_VIOLATION is rather hard to achieve in a test.
// Basically, you will mostly (always?) get a ERROR_SHARING_VIOLATION, unless you would
// do the test via the network (e.g. using a share). Note that using the "\\<computer>\C$"
// share will not cut it.
//
// Also note, that as of current (fall 2024), the .NET runtime does not raise IOException
// with ERROR_LOCK_VIOLATION. Since technically, this error is a potential result of a
// locking issue, we check for it anyway.
if (errorCode == NativeMethods.ERROR_LOCK_VIOLATION ||
errorCode == NativeMethods.ERROR_SHARING_VIOLATION)
{
return true;
}
}
return false;
}
}
Expand Down
Loading

0 comments on commit b605de8

Please sign in to comment.