WhoHolds

Single dependency-free Native AOT Windows CLI executable to resolve holder processes to a file. Application and CI pipeline is built with C#. Optionally provides scriptable JSON output and exit codes.

This document describes the challenges I faced while working on this topic and how I solved them.


Information about using the CLI executable can be found in the README.md file of the GitHub repository.

Architecture

The application follows a four layer architecture:

1. Native Method Signatures (WhoHolds.Core)

No logic lives here. Essentially this layer is a single class represented by NativeMethods, which defines .dll function calls:

[LibraryImport(_restartManager, StringMarshalling = StringMarshalling.Utf16)]
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
internal static partial SystemErrorCode RmGetList(
  uint dwSessionHandle,
  out uint pnProcInfoNeeded,
  ref uint pnProcInfo,
  [In, Out] RmProcessInfo[]? rgAffectedApps,
  out RmRebootReason lpdwRebootReasons
);
DWORD RmGetList(
  [in]                DWORD              dwSessionHandle,
  [out]               UINT               *pnProcInfoNeeded,
  [in, out]           UINT               *pnProcInfo,
  [in, out, optional] RM_PROCESS_INFO [] rgAffectedApps,
  [out]               LPDWORD            lpdwRebootReasons
);

LibraryImportAttribute: source generates the marshalling code. This is preferred to runtime generation for Native AOT applications, because:

  • Trimming and AOT analysis can verify the generated code,
  • You can inspect and debug the generated code,
  • Startup is faster,
  • Marshalling choices are explicit: StringMarshalling must be specified.

DefaultDllImportSearchPathsAttribute: limits the search for the .dll to a trusted location: Windows system directory.

2. Native Method Handlers (WhoHolds.Core)

This layer defines classes that orchestrate logic using the method definitions inside NativeMethods.

A perfect fit for this is the RestartManagerSession - it uses the Restart Manager functions defined in NativeMethods to:

  1. Start a Restart Manager session.
  2. Registers the files paths passed in the constructor.
  3. Lists the HolderProcess instances to the registered file paths.
  4. Closes the Restart Manager session on disposal.

Failures are translated to Result objects with the corresponding errors via RmFunctionReference based on the returned SystemErrorCode and function name. I reached for the Result pattern because:

  • An Exception should only be thrown in unexpected cases (an application failure),
  • It provides a uniform output for to the CLI - in this case the Result is an envelope as well, this means the output becomes scriptable via JSON serialization.

3. WhoHolds.Core Public API

Public API for the CLI project. WhoHolds static class provides a uniform Result output for the CLI project. Guards against:

  • Paths that are pointing to a directory,
  • Non-existing files,
  • Files that require administrator privileges,
  • And invalid file paths.

4. CLI

Entry point of the application, defines commands and options for the user.

The CliJsonContext (inherits from: JsonSerializerContext) provides source generated JSON serialization for the Result envelope using the ResultJsonConverter. This converter defines the operation of writing the JSON document piece-by-piece - literally, with methods like writer.WriteStartObject(), writer.WritePropertyName(...) and writer.WriteBoolean(...).
Uniform JSON key naming is ensured via JsonNamingPolicy.CamelCase.ConvertName(...) - I used this to pass field names via nameof(). This results in a serialization that has no primitive obsession.

Testing

Testing is done via the excellent TUnit framework, it provides:

  • Fast source generated tests: the framework generates a single test class per method
  • Parallelization,
  • And a way to set up the testing infrastructure in a way where I do not have to fight namespaces (I used NUnit previously).

Required testing infrastructure can be instantiated in many ways:

  • Per test method via class instance field,
  • Per test class via static class field,
  • Injected and managed by the framework (initialized and disposed asynchronously)

This testing is framework is the most performant and most flexible one I've used so far, give it a star on GitHub!

In general for .NET, it is discouraged to reference test projects from another when testing under Microsoft Testing Platform. Test dependencies and configurations leak: packages, module initializers or assembly-level config. However, I did it before knowing this which caused not only the above but test duplications as well. As a result I had to refactor my tests and put the shared code into a separate project, which only references TUnit.Core and does not create an executable out of the project.

CI Pipeline

I used GitHub Actions to run my CI pipeline. It reads a .yml workflow file that describes the pipeline's steps, and the final step runs the Pipeline project, an unconventional C# codebase that holds most of the CI logic. The ModularPipelines NuGet package allowed me to do this - which is created by the same person as the TUnit framework.

The package provides a builder to register requirements like WindowsRequirement - which constraints the CI host to Windows, or custom ones like CppBuildToolRequirement that verifies whether the machine has the C++ build tools needed for Native AOT compilation. The requirements are followed by the actual modules of the pipeline: RestoreModule, BuildModule, TestModule, PublishModule, SmokeTestModule and DraftReleaseModule. Last three modules define a skip condition: they will only run on tag pushes.

Testing the Pipeline

Because the pipeline is written in C#, it can be tested like any other code. But how do you test a CI pipeline, and where do you draw the line? Running the full pipeline from inside the tests isn't an option: the pipeline already runs the tests as one of its steps, so the tests would launch the pipeline again, which would run the tests again, and so on.

Maintaining tests for a fully mocked pipeline isn't a good option either: any change to a dependency, such as a new service, a new configuration value, or even a tweak to a settings class, can break the tests.

Instead, I unit test individual requirements and modules with mocked configuration and services. That covers enough to catch serious errors before they reach production, without the pipeline having to run itself.

Another complication is the architecture of ModularPipelines: executing a Module requires internal wiring from the library:

So I decided to keep my modules as thin glue. Static methods hold the individual blocks of logic, and the module itself only calls and wires them together:


[DependsOn<PublishModule>]
public sealed class SmokeTestModule(IOptions<PipelineSettings> options) : Module
{
    protected override ModuleConfiguration Configure()
    {
        return ModuleConfiguration.Create().WithTagPushSkip(options.Value.IsTagPush).Build();
    }

    protected override async Task ExecuteModuleAsync(
        IModuleContext context,
        CancellationToken cancellationToken
    )
    {
        var publishedBuild = (await context.GetModule<PublishModule>()).EnsurePublished();

        var result = await ExecuteVersionCommandAsync(
            publishedBuild,
            context.Shell.Command,
            cancellationToken
        );

        ThrowIfVersionOutputInvalid(result.StandardOutput, publishedBuild.Version);
    }

    public static async Task<CommandResult> ExecuteVersionCommandAsync(
        PublishedBuild publishedBuild,
        ICommandContext context,
        CancellationToken cancellationToken
    )
    {
        return await context.ExecuteCommandLineTool(
            new GenericCommandLineToolOptions(publishedBuild.FilePath)
            {
                Arguments = ["--version"],
            },
            cancellationToken: cancellationToken
        );
    }

    ...
}

The tradeoff is public static methods on the modules, but it makes them much easier to test. As of v3.2.8, the package doesn't have good testing support, though that is set to change in v4.