A notable limitation of the .NET environment-variable APIs as of .NET 8 is the inability to define environment variables without a value or, to put it differently, with a value that is the empty string.
Both Windows and Unix-like platforms do support this natively, however, via system calls, which is why calling the latter directly is of interest.
- Bringing this ability to .NET itself in the future is the subject of GitHub issue #50554.
While using P/Invoke declarations to access the relevant system calls works as expected on Windows, it does not on Unix-like platforms, as of .NET 8:
- .NET does not see the P/Invoke-mediated environment modifications and continues to report the old environment, both via
Environmentand when launching child processes.
Is this a bug, or is the code below missing something?
Sample C# console-application code, tested with version 8.0.101 of the .NET SDK:
Run on a Unix-like platform (Linux or MacOS), as the content of the
Program.csfile in a project created withdotnet new consoleAdd
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>inside the<PropertyGroup>element of the*.csprojfile in order for the project to compile.
using System.Diagnostics;
using System.Runtime.InteropServices;
partial class Program
{
[LibraryImport("libc", StringMarshalling = StringMarshalling.Utf8)]
private static partial int setenv(string name, string value, int overwrite);
[LibraryImport("libc", StringMarshalling = StringMarshalling.Utf8)]
private static partial IntPtr getenv(string name); // Convert to string with Marshal.PtrToStringUTF8()
[LibraryImport("libc", StringMarshalling = StringMarshalling.Utf8)]
private static partial int system(string command);
static void ThrowSysCallError() => throw new System.ComponentModel.Win32Exception(Marshal.GetLastSystemError());
static void Main(string[] args)
{
// Define / set env. var. 'FOO' with / to value 'new'
if (-1 == setenv("FOO", "new", 1)) ThrowSysCallError();
Console.WriteLine(
$"In-process value of FOO after setting it to 'new', via syscall: [{Marshal.PtrToStringUTF8(getenv("FOO"))}]"
);
Console.Write(
"Value of FOO in a syscall-launched child process: "
);
system("echo [$FOO]");
Console.WriteLine(
$"In-process value of FOO per Environment.GetEnvironmentVariable(\"FOO\"): [{Environment.GetEnvironmentVariable("FOO")}]"
);
Console.Write(
"Value of FOO in a .NET-launched child process: "
);
Process.Start(
new ProcessStartInfo()
{
FileName = "sh",
ArgumentList = { "-c", @"echo [$FOO]" },
}
)?.WaitForExit();
}
}
The expected output would be:
In-process value of FOO after setting it to 'new', via syscall: [new]
Value of FOO in a syscall-launched child process: [new]
In-process value of FOO per Environment.GetEnvironmentVariable("FOO"): [new]
Value of FOO in a .NET-launched child process: [new]
The actual output is - note how .NET apparently doesn't see the FOO variable that the setenv() syscall set:
In-process value of FOO after setting it to 'new', via syscall: [new]
Value of FOO in a syscall-launched child process: [new]
In-process value of FOO per Environment.GetEnvironmentVariable("FOO"): []
Value of FOO in a .NET-launched child process: []
This was an interesting rabbit hole, thank you.
If we look at the runtime source,
GetEnvironmentVariablegets delegated to the platform-specificGetEnvironmentVariableCore.On Windows, this results in the appropriate interop call to
Kernel32(also whenSet-ing). Nothing to see there.On Unix, things look a little more interesting. It contains a
staticdictionary caches_environment. If we're very lucky and it hasn't been populated yet, we'll get our desired interop call togetenv.Unfortunately, any call to
GetEnvironmentVariables(the plural version) andSetEnvironmentVariablewill populate the cache - courtesy ofEnsureEnvironmentCached.Once that happens, all operations will happen on the cache. So if anything in the runtime has called either of them (it does. in so many places), we're out of luck.
This explains our first missing value. Calling
GetEnvironmentVariablewill likely end up with us being served a cached value, skipping thegetenvcall.But what about the child process? Shouldn't that inherit the process's environment - which we *did* set via
setenv?If only we were so lucky. Like before,
Process.Start()gets delegated to the platform-specificStartCore. Here it is in all its glory. Now notice thestring[] envp = CreateEnvp(startInfo);line. If we keep digging, thisenvpkeeps getting passed deeper, eventually ending up in theexecvesyscall, afterforking.This is a problem because
CreateEnvpgets its values fromProcessStartInfo.Environment, which gets its values from... our good friendEnvironment.GetEnvironmentVariables()which we know from the last episode will serve us cached values.The take home message from all this is that, in Unix/Linux, we should be safe if we stick to
Environment.[Set,Get]EnvironmentVariablecalls, as we'll be confined to operating with cached values.But as you've established in your question, we're out of luck if we want to use empty variables.