Cross-platform without complexity: .NET interop

Spread the love

The cross-platform project has a shared library now. Wouldn’t it be fun to try to build an interop project using P/Invoke with this library? To account for the addition of .NET projects, we’ll need to revisit the project structure:

  • inc (public header file root)
    • core (header files for `core` lib)
    • lib (header files for shared `lib`)
  • src (source code root)
    • app (executable target)
    • core (static lib target)
    • interop (.NET interop proj)
    • lib (shared lib target)
  • test (test code root)
    • core (test for `core` lib)
    • interop (test for interop proj)

While it is possible to use CMake to build .NET projects, this typically relies on Visual Studio-based generators. If we want to preserve our cross-platform experience, why not just directly use the .NET SDK which is already cross-platform?

Let’s start with the project files. We need a few traversal projects to easily build the whole tree without relying on a .sln file. First, the top-level dirs.proj:

<Project Sdk="Microsoft.Build.Traversal">
  <ItemGroup>
    <ProjectReference Include="src\dirs.proj" />
    <ProjectReference Include="test\dirs.proj" />
  </ItemGroup>
</Project>

Next, src\dirs.proj:

<Project Sdk="Microsoft.Build.Traversal">
  <ItemGroup>
    <ProjectReference Include="interop\Sample.Interop.csproj" />
  </ItemGroup>
</Project>

Finally, test\dirs.proj:

<Project Sdk="Microsoft.Build.Traversal">
  <ItemGroup>
    <ProjectReference Include="interop\Sample.Interop.Test.csproj" />
  </ItemGroup>
</Project>

To make sure the proper project SDKs are loaded, we also want to put global.json at the root:

{
  "sdk": {
    "version": "3.1.302"
  },
  "msbuild-sdks": {
    "Microsoft.Build.Traversal" : "2.0.52"
  }
}

The Sample.Interop.csproj will be a simple .NET Core 3.1 class library. Owing to the simplifications in the SDK-style project format, the file is minimal:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>
</Project>

For Sample.Interop.Test.csproj, we’ll use xUnit.net (the framework of choice for the .NET Runtime project itself):

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="FluentAssertions" Version="5.10.3" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.0" />
    <PackageReference Include="xunit" Version="2.4.1" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
    <PackageReference Include="coverlet.collector" Version="1.3.0">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\..\src\interop\Sample.Interop.csproj" />
  </ItemGroup>
</Project>

Our goal is to create an interop wrapper for the native shared library. We will specify its behavior in test\interop\SampleWrapperTest.cs:

namespace Sample.Interop.Test
{
    using FluentAssertions;
    using Xunit;

    public sealed class SampleWrapperTest
    {
        [Fact]
        public void GetName()
        {
            using var hello = new SampleWrapper("world");

            hello.Name.Should().Be("world");
        }
    }
}

Before we actually integrate with the shared library, we can create a placeholder implementation with no dependencies in src\interop\SampleWrapper.cs:

namespace Sample.Interop
{
    using System;

    public sealed class SampleWrapper : IDisposable
    {
        public SampleWrapper(string name)
        {
            this.Name = name;
        }

        public string Name { get; private set; }

        public void Dispose()
        {
        }
    }
}

At this point, we can validate that everything works by running dotnet build:

Microsoft (R) Build Engine version 16.6.0+5ff7b0c9e for .NET Core
Copyright (C) Microsoft Corporation. All rights reserved.

  Determining projects to restore...
  Restored X:\root\CMakeSampleVS\test\dirs.proj (in 244 ms).
  Restored X:\root\CMakeSampleVS\dirs.proj (in 244 ms).
  Restored X:\root\CMakeSampleVS\src\dirs.proj (in 244 ms).
  Restored X:\root\CMakeSampleVS\src\interop\Sample.Interop.csproj (in 244 ms).
  Restored X:\root\CMakeSampleVS\test\interop\Sample.Interop.Test.csproj (in 770 ms).
  Sample.Interop -> X:\root\CMakeSampleVS\src\interop\bin\x64\Debug\netcoreapp3.1\Sample.Interop.dll
  Sample.Interop.Test -> X:\root\CMakeSampleVS\test\interop\bin\x64\Debug\netcoreapp3.1\Sample.Interop.Test.dll

Build succeeded.
    0 Warning(s)
    0 Error(s)

Time Elapsed 00:00:04.49

…followed by dotnet test:

Test run for X:\root\CMakeSampleVS\test\interop\bin\x64\Debug\netcoreapp3.1\Sample.Interop.Test.dll(.NETCoreApp,Version=v3.1)
Microsoft (R) Test Execution Command Line Tool Version 16.6.0
Copyright (c) Microsoft Corporation.  All rights reserved.

Starting test execution, please wait...

A total of 1 test files matched the specified pattern.

Test Run Successful.
Total tests: 1
     Passed: 1
 Total time: 2.2073 Seconds

As promised, the same steps work perfectly on Linux (assuming .NET SDK is installed):

Test run for /mnt/x/root/CMakeSampleVS/test/interop/bin/Debug/netcoreapp3.1/Sample.Interop.Test.dll(.NETCoreApp,Version=v3.1)
Microsoft (R) Test Execution Command Line Tool Version 16.6.0
Copyright (c) Microsoft Corporation.  All rights reserved.

Starting test execution, please wait...

A total of 1 test files matched the specified pattern.

Test Run Successful.
Total tests: 1
     Passed: 1
 Total time: 1.9103 Seconds

As a bonus, that command was run against the targets built from the Windows toolset. 🙌 But of course, we are not done yet. The first problem is that .NET build outputs go into the source folder by default. Look at all these untracked files in git status:

Untracked files:
  (use "git add <file>..." to include in what will be committed)
        obj/
        src/interop/bin/
        src/interop/obj/
        src/obj/
        test/interop/bin/
        test/interop/obj/
        test/obj/

Oh, dear. To align with the CMake experience, we want a read-only source tree. Luckily, there is an easy enough set of workarounds we can do to retarget all projects, as suggested in a .NET SDK GitHub issue. We need to create a Directory.Build.props file at the root as follows:

<Project>
  <PropertyGroup>
    <_ProjectDir>$(MSBuildProjectDirectory)\</_ProjectDir>
    <_RelativeDir>$(_ProjectDir.Substring($(MSBuildThisFileDirectory.Length)))\</_RelativeDir>
    <_RootDir>$(MSBuildThisFileDirectory)out\</_RootDir>
    <BaseIntermediateOutputPath>$(_RootDir)obj\$(_RelativeDir)_$(MSBuildProjectName)\</BaseIntermediateOutputPath>
    <IntermediateOutputPath>$(BaseIntermediateOutputPath)$(Configuration)\</IntermediateOutputPath>
    <BaseOutputPath>$(_RootDir)build\</BaseOutputPath>
    <_OutPrefix>Linux-Clang</_OutPrefix>
    <_OutPrefix Condition="'$(OS)' == 'Windows_NT'">x64</_OutPrefix>
    <_OutSuffix>$(Configuration)</_OutSuffix>
    <_OutSuffix Condition="'$(_OutSuffix)' == ''">Debug</_OutSuffix>
    <OutDir>$(BaseOutputPath)$(_OutPrefix)-$(_OutSuffix)\</OutDir>
  </PropertyGroup>
</Project>

Note that we’re reusing the CMake out folder as our root path. From there, we specify an obj folder as our intermediate output; it is important here that we keep outputs from distinct projects in separate folders as we do not want collisions between generated files such as those created during restore. The final output directory is the same folder as used in CMake for the specific build type and platform target, e.g. Linux-Clang-Debug or x64-Release. With these changes, we end up with an output folder structure like the following:

  • out
    • build
      • x64-Debug
    • obj
      • src
        • interop
          • _Sample.Interop
        • _dirs
      • test
        • interop
          • _Sample.Interop.Test
        • _dirs
      • _dirs

In fact, our .gitignore file can be simplified down to just these two entries:

# Build outputs
out/

# Visual Studio
.vs/

Since the .NET DLLs and the shared library are in the same folder now, the native dependencies should be implicitly available. (Do keep in mind that we would have to remember to run the build script before attempting to load the native library, however.) But there is still one small issue here; shared libraries in Linux typically have the lib prefix, while they are otherwise unadorned on Windows. This whole time we were generating libsample-lib.so and sample-lib.dll on each respective platform without even noticing! Of course, CMake has an option for that:

set_property(TARGET sample-lib
    PROPERTY PREFIX ""
)

Now the binary name is the same on both platforms, which means we can finally write the real interop code in src\interop\SampleWrapper.cs:

namespace Sample.Interop
{
    using System;
    using System.Runtime.InteropServices;
    using Microsoft.Win32.SafeHandles;

    public sealed class SampleWrapper : IDisposable
    {
        private readonly NativeMethods.SampleHandle handle;
        private readonly Lazy<string> name;

        public SampleWrapper(string name)
        {
            this.handle = NativeMethods.SampleInit(name);
            this.name = new Lazy<string>(this.GetName);
        }

        public string Name => this.name.Value;

        public void Dispose()
        {
            if (!this.handle.IsClosed)
            {
                this.handle.Dispose();
            }
        }

        private string GetName()
        {
            IntPtr str = NativeMethods.SampleGetName(this.handle);
            return Marshal.PtrToStringAnsi(str);
        }

        private static class NativeMethods
        {
            private const string Lib = "sample-lib";

            public sealed class SampleHandle : SafeHandleZeroOrMinusOneIsInvalid
            {
                public SampleHandle()
                    : base(true)
                {
                }

                protected override bool ReleaseHandle()
                {
                    SampleDestroy(this.handle);
                    return true;
                }
            }

            [DllImport(Lib)]
            public static extern SampleHandle SampleInit(
                [MarshalAs(UnmanagedType.LPStr)] string name);

            [DllImport(Lib)]
            public static extern IntPtr SampleGetName(SampleHandle p);

            [DllImport(Lib)]
            public static extern void SampleDestroy(IntPtr p);
        }
    }
}

For memory safety, we are implementing the Dispose pattern and using a SafeHandle to wrap the native pointer. Since the shared library returns a non-owning pointer to a char for the name string, we have to do a bit of marshaling magic. We use Lazy here because the name cannot change after construction and repeatedly doing this interop/marshal call would be an unnecessary expense.

GitHub repo with the changes: CMakeSampleVS

After running build.cmd / build.sh, dotnet build, and dotnet test, we see that everything still works on both platforms. However, we haven’t accounted for a “one click” command line build or the VS IDE experience. We can cover these next time.

2 thoughts on “Cross-platform without complexity: .NET interop

  1. Pingback: Cross-platform without complexity: finishing up – WriteAsync .NET

  2. Pingback: Letter Boxed: out with the old – WriteAsync .NET

Leave a Reply

Your email address will not be published. Required fields are marked *