Add basic C# interop for the WSLC SDK API#40066
Add basic C# interop for the WSLC SDK API#40066florelis wants to merge 2 commits intomicrosoft:feature/wsl-for-appsfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a basic C# P/Invoke interop surface for the WSLC SDK (wslcsdk.dll) and wires it into the NuGet package so managed consumers can use the native API without a separate managed assembly build step.
Changes:
- Add
Microsoft.WSL.Containers.Interop.cs(P/Invoke declarations + small helper utilities) to the package and auto-include it in consumer builds via.targets. - Update developer build docs with a Release build invocation note.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| nuget/Microsoft.WSL.Containers/build/net/Microsoft.WSL.Containers.targets | Auto-includes the C# interop source file into consuming .NET projects. |
| nuget/Microsoft.WSL.Containers/build/net/Microsoft.WSL.Containers.Interop.cs | New C# P/Invoke surface mapping wslcsdk.h plus helper methods/types. |
| doc/docs/dev-loop.md | Adjusts Release build instructions in the dev loop documentation. |
|
|
||
| <Import Project="$(MSBuildThisFileDirectory)..\Microsoft.WSL.Containers.common.targets" /> | ||
|
|
||
| <ItemGroup> |
There was a problem hiding this comment.
The .targets file unconditionally adds a C# source file via . That will be imported for any SDK-style .NET project referencing the package (including VB/F#), and can break builds because those project types can’t compile .cs files. Consider conditioning this ItemGroup on the project language (e.g., IsCSharpProject / MSBuildProjectExtension == .csproj) or moving the file to a C#-only asset path so non-C# consumers aren’t impacted.
| <ItemGroup> | |
| <ItemGroup Condition="'$(MSBuildProjectExtension)' == '.csproj'"> |
There was a problem hiding this comment.
For now I think we only care about C#, and the idea is that we will have a compiled binary later on, not the plain .cs
There was a problem hiding this comment.
Still seems like an easy guard just in case.
| // This file is a direct mapping of wslcsdk.h. All structures, enums, delegates, | ||
| // and entry points are exposed for use by managed callers. | ||
| // | ||
| // Notes: | ||
| // - Opaque settings structs (WslcSessionSettings, WslcContainerSettings, | ||
| // WslcProcessSettings) must only be manipulated through the SDK functions. | ||
| // - Handle types (WslcSession, WslcContainer, WslcProcess) are IntPtr values | ||
| // that must be released with the corresponding WslcRelease* function. | ||
| // - Output strings allocated by the SDK (errorMessage, inspectData) are | ||
| // CoTaskMem-allocated; free them with Marshal.FreeCoTaskMem after use. | ||
| // - Delegate instances passed as callbacks must be kept alive (prevent GC) | ||
| // for the entire duration they may be invoked by native code. |
There was a problem hiding this comment.
Most of the SDK “settings” APIs store pointers to caller-provided strings/arrays rather than copying them (e.g., WslcInitSessionSettings assigns displayName/storagePath directly; WslcInitContainerSettings assigns imageName; various setters assign PCSTR pointers). With the current P/Invoke signatures using string/string[] and struct arrays, the marshaller’s backing buffers are not guaranteed to stay valid across subsequent calls (GC compaction / temporary unmanaged copies), so later calls like WslcCreateSession/WslcCreateContainer can observe dangling pointers. The interop layer should either (1) change these parameters to IntPtr and require callers to allocate/pin unmanaged memory for the full lifetime of the settings object, or (2) add managed wrapper types that pin/own the unmanaged memory until after CreateSession/CreateContainer completes.
There was a problem hiding this comment.
I would be okay with this for the first pass. It only means that it needs to be used paying attention to these things, and I'll address the rough edges before the final version of the projection.
There was a problem hiding this comment.
I don't see how a caller can avoid the situation described here; if you are at the whims of GC then we just end up looking flaky.
| [MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 2)] WslcContainerPortMapping[] portMappings, | ||
| uint portMappingCount); | ||
|
|
||
| [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] | ||
| public static extern int WslcSetContainerSettingsVolumes( | ||
| ref WslcContainerSettings containerSettings, | ||
| [MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 2)] WslcContainerVolume[] volumes, | ||
| uint volumeCount); | ||
|
|
||
| [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] | ||
| public static extern int WslcSetContainerSettingsNamedVolumes( | ||
| ref WslcContainerSettings containerSettings, | ||
| [MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 2)] WslcContainerNamedVolume[] namedVolumes, |
There was a problem hiding this comment.
WslcSetContainerSettingsPortMappings/Volumes/NamedVolumes are declared with managed arrays, but the native implementation stores the raw pointers (it does not deep-copy the arrays/strings). That means the marshalled unmanaged buffers created for these calls can be freed immediately after the setter returns, leaving the settings object holding dangling pointers and causing failures later in WslcCreateContainer. These setters should be exposed as IntPtr + count (caller-managed unmanaged memory) or wrapped with a managed “settings builder” that allocates/pins unmanaged arrays for the lifetime of the settings object.
| [MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 2)] WslcContainerPortMapping[] portMappings, | |
| uint portMappingCount); | |
| [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] | |
| public static extern int WslcSetContainerSettingsVolumes( | |
| ref WslcContainerSettings containerSettings, | |
| [MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 2)] WslcContainerVolume[] volumes, | |
| uint volumeCount); | |
| [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] | |
| public static extern int WslcSetContainerSettingsNamedVolumes( | |
| ref WslcContainerSettings containerSettings, | |
| [MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 2)] WslcContainerNamedVolume[] namedVolumes, | |
| IntPtr portMappings, | |
| uint portMappingCount); | |
| [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] | |
| public static extern int WslcSetContainerSettingsVolumes( | |
| ref WslcContainerSettings containerSettings, | |
| IntPtr volumes, | |
| uint volumeCount); | |
| [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] | |
| public static extern int WslcSetContainerSettingsNamedVolumes( | |
| ref WslcContainerSettings containerSettings, | |
| IntPtr namedVolumes, |
| [DllImport(DllName, CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Ansi)] | ||
| public static extern int WslcSetProcessSettingsCurrentDirectory( | ||
| ref WslcProcessSettings processSettings, | ||
| [MarshalAs(UnmanagedType.LPStr)] string currentDirectory); | ||
|
|
||
| [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] | ||
| public static extern int WslcSetProcessSettingsCmdLine( | ||
| ref WslcProcessSettings processSettings, | ||
| [MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.LPStr)] string[] argv, | ||
| UIntPtr argc); | ||
|
|
||
| [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] | ||
| public static extern int WslcSetProcessSettingsEnvVariables( | ||
| ref WslcProcessSettings processSettings, | ||
| [MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.LPStr)] string[] keyValue, | ||
| UIntPtr argc); | ||
|
|
There was a problem hiding this comment.
WslcSetProcessSettingsCurrentDirectory/CmdLine/EnvVariables are exposed with managed strings/string[] parameters, but the native layer stores the pointers (commandLine/environment/currentDirectory) for later use rather than copying. With the current signatures, the marshaller’s buffers won’t remain valid across calls (and may be moved by GC), which can lead to use-after-free when creating/starting processes. Consider switching these parameters to IntPtr (caller-owned unmanaged allocations) or providing managed wrappers that pin/own the argv/env/currentDirectory memory until after the process is created.
| [DllImport(DllName, CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Ansi)] | |
| public static extern int WslcSetProcessSettingsCurrentDirectory( | |
| ref WslcProcessSettings processSettings, | |
| [MarshalAs(UnmanagedType.LPStr)] string currentDirectory); | |
| [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] | |
| public static extern int WslcSetProcessSettingsCmdLine( | |
| ref WslcProcessSettings processSettings, | |
| [MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.LPStr)] string[] argv, | |
| UIntPtr argc); | |
| [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] | |
| public static extern int WslcSetProcessSettingsEnvVariables( | |
| ref WslcProcessSettings processSettings, | |
| [MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.LPStr)] string[] keyValue, | |
| UIntPtr argc); | |
| // Native entry points that accept unmanaged pointers. These map directly to the | |
| // wslcsdk.dll exports and are used by the managed wrappers below. | |
| [DllImport(DllName, CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Ansi, EntryPoint = "WslcSetProcessSettingsCurrentDirectory")] | |
| private static extern int WslcSetProcessSettingsCurrentDirectory_Native( | |
| ref WslcProcessSettings processSettings, | |
| IntPtr currentDirectory); | |
| [DllImport(DllName, CallingConvention = CallingConvention.StdCall, EntryPoint = "WslcSetProcessSettingsCmdLine")] | |
| private static extern int WslcSetProcessSettingsCmdLine_Native( | |
| ref WslcProcessSettings processSettings, | |
| IntPtr argv, | |
| UIntPtr argc); | |
| [DllImport(DllName, CallingConvention = CallingConvention.StdCall, EntryPoint = "WslcSetProcessSettingsEnvVariables")] | |
| private static extern int WslcSetProcessSettingsEnvVariables_Native( | |
| ref WslcProcessSettings processSettings, | |
| IntPtr keyValue, | |
| UIntPtr argc); | |
| // Managed wrappers that allocate unmanaged memory for arguments and call the | |
| // IntPtr-based native entry points above. The allocations are intentionally not | |
| // freed here to ensure that the native layer can safely hold onto the pointers | |
| // for the lifetime of the process settings / process. | |
| public static int WslcSetProcessSettingsCurrentDirectory( | |
| ref WslcProcessSettings processSettings, | |
| string currentDirectory) | |
| { | |
| IntPtr dirPtr = IntPtr.Zero; | |
| if (!string.IsNullOrEmpty(currentDirectory)) | |
| { | |
| dirPtr = Marshal.StringToHGlobalAnsi(currentDirectory); | |
| } | |
| return WslcSetProcessSettingsCurrentDirectory_Native(ref processSettings, dirPtr); | |
| } | |
| public static int WslcSetProcessSettingsCmdLine( | |
| ref WslcProcessSettings processSettings, | |
| string[] argv, | |
| UIntPtr argc) | |
| { | |
| if (argv == null || argv.Length == 0 || argc == UIntPtr.Zero) | |
| { | |
| return WslcSetProcessSettingsCmdLine_Native(ref processSettings, IntPtr.Zero, UIntPtr.Zero); | |
| } | |
| // Allocate unmanaged strings for each argument. | |
| int count = (int)argc; | |
| if (count > argv.Length) | |
| { | |
| count = argv.Length; | |
| } | |
| IntPtr argvArrayPtr = IntPtr.Zero; | |
| try | |
| { | |
| argvArrayPtr = Marshal.AllocHGlobal(IntPtr.Size * count); | |
| for (int i = 0; i < count; i++) | |
| { | |
| IntPtr argPtr = argv[i] != null | |
| ? Marshal.StringToHGlobalAnsi(argv[i]) | |
| : IntPtr.Zero; | |
| Marshal.WriteIntPtr(argvArrayPtr, i * IntPtr.Size, argPtr); | |
| } | |
| } | |
| catch | |
| { | |
| // If allocation fails partway through, leave any successfully allocated | |
| // strings and the array in place; they will simply not be referenced. | |
| throw; | |
| } | |
| return WslcSetProcessSettingsCmdLine_Native(ref processSettings, argvArrayPtr, (UIntPtr)count); | |
| } | |
| public static int WslcSetProcessSettingsEnvVariables( | |
| ref WslcProcessSettings processSettings, | |
| string[] keyValue, | |
| UIntPtr argc) | |
| { | |
| if (keyValue == null || keyValue.Length == 0 || argc == UIntPtr.Zero) | |
| { | |
| return WslcSetProcessSettingsEnvVariables_Native(ref processSettings, IntPtr.Zero, UIntPtr.Zero); | |
| } | |
| int count = (int)argc; | |
| if (count > keyValue.Length) | |
| { | |
| count = keyValue.Length; | |
| } | |
| IntPtr envArrayPtr = IntPtr.Zero; | |
| try | |
| { | |
| envArrayPtr = Marshal.AllocHGlobal(IntPtr.Size * count); | |
| for (int i = 0; i < count; i++) | |
| { | |
| IntPtr kvPtr = keyValue[i] != null | |
| ? Marshal.StringToHGlobalAnsi(keyValue[i]) | |
| : IntPtr.Zero; | |
| Marshal.WriteIntPtr(envArrayPtr, i * IntPtr.Size, kvPtr); | |
| } | |
| } | |
| catch | |
| { | |
| // As above, in case of failure, already-allocated values are intentionally | |
| // not freed here. | |
| throw; | |
| } | |
| return WslcSetProcessSettingsEnvVariables_Native(ref processSettings, envArrayPtr, (UIntPtr)count); | |
| } |
nuget/Microsoft.WSL.Containers/build/net/Microsoft.WSL.Containers.Interop.cs
Show resolved
Hide resolved
|
I'm not sure that the C ABI as defined/implemented is a good fit for any direct PInvoke only calling. It is too reliant on the caller managing memory for the various settings for that to work without forcing C# callers to pin everything first. If we are to include it, it should be that way (callers must pin and pass pointers in). But honestly that seems like such a pain to use that I'm not sure it is worth it. |
Summary of the Pull Request
This adds a basic P/Invoke C# wrapper for wslcsdh.h.
PR Checklist
Detailed Description of the Pull Request / Additional comments
The interop wrapper was mostly generated by Copilot.
The wrapper is included as source (
.cs) in the NuGet package, and the.targetsfile automatically adds it to consumer projects. The file is compiled by the consumer, so we don't have to change anything about our build process for now.I believe this to be a low-risk change as there is no change in the build process. If a caller has any issue with this interop wrapper, they can exclude it from their project with
<Compile Remove="[path]" />and use the unmanaged DLL directly. Tests and a more idiomatic wrapper for C# will come later; this is intended as a first approach if it can be included in the next private build.I also updated the dev docs to mention a flag that is needed when building the Release configuration, as I needed it to build the NuGet locally.
Validation Steps Performed
I created a sample consumer project. With only including the package and no additional configuration, I was able to use the API. The project built with no issues (only a few warnings about possible null references), and it ran the initial steps just fine - creating the settings objects and pulling the image. It failed when trying to create a container, but I'm not sure if that was due to an issue in the projection or something about my setup.