Skip to content

UseSystemResourceKeys strips the .NET message table, so exceptions read as resource keys

Not a game finding. This is a .NET SDK / publish behaviour, and nothing on this page depends on the Stationeers version. The frontmatter carries a game version because the schema requires one.

<UseSystemResourceKeys>true</UseSystemResourceKeys> replaces exception messages from System.* assemblies with the bare resource key that would have looked the message up. An operator reading a failure from a binary published that way sees IO_SharingViolation_File, <path> where they expected an English sentence. The consequence for code is the sharper one: never classify an exception by its message text.

What the setting does

Microsoft's trimming-options reference, verbatim:

UseSystemResourceKeys | When set to true, strips exception messages for System.* assemblies. When an exception is thrown from a System.* assembly, the message is a simplified resource ID instead of the full message.

The MSBuild property becomes a runtime host configuration option, which the BCL reads through AppContext. From dotnet/runtime, src/libraries/Common/src/System/SR.cs:

private static readonly bool s_usingResourceKeys = GetUsingResourceKeysSwitchValue();

// This method is a target of ILLink substitution.
private static bool GetUsingResourceKeysSwitchValue() =>
    AppContext.TryGetSwitch("System.Resources.UseSystemResourceKeys", out bool usingResourceKeys)
        ? usingResourceKeys : false;

internal static bool UsingResourceKeys() => s_usingResourceKeys;

and the two paths that produce the visible text:

// no-argument message: the key, alone
if (UsingResourceKeys())
{
    return resourceKey;
}

// formatted message: the key, then the arguments, comma-separated
internal static string Format(string resourceFormat, object? p1)
{
    if (UsingResourceKeys())
    {
        return string.Join(", ", resourceFormat, p1);
    }

    return string.Format(resourceFormat, p1);
}

string.Join(", ", resourceFormat, p1) is where the odd Key, argument shape comes from. It is not a truncated sentence; it is the key and the format arguments with the message thrown away.

It is opt-in, and AOT does not turn it on for you. Verified against the SDK on disk (10.0.400). The base SDK emits the host configuration option only when the property is set at all:

<RuntimeHostConfigurationOption Include="System.Resources.UseSystemResourceKeys"
                                Condition="'$(UseSystemResourceKeys)' != ''"
                                Value="$(UseSystemResourceKeys)"
                                Trim="true" />

and the trimming targets that PublishTrimmed / PublishAot pull in default it to false:

<UseSystemResourceKeys Condition="'$(UseSystemResourceKeys)' == ''">false</UseSystemResourceKeys>

The two places that do default it on are the Blazor WebAssembly SDKs (true) and ILCompiler when IlcDisableReflection is set. So a project that sees resource keys in its exception messages chose them, and can un-choose them by dropping one line. TestRig.Cli chooses them deliberately, at TestRig.Cli.csproj:20, alongside <PublishAot>true</PublishAot> (17) and <InvariantGlobalization>true</InvariantGlobalization> (19).

Measured on this machine

Two builds of the same console program, net10.0, SDK 10.0.400, differing only in -p:UseSystemResourceKeys=. Each provokes a sharing violation (delete a file another handle holds with FileShare.None) and a lock violation (write into a byte range another handle has locked), then prints the exception type, HResult and Message. Absolute temp paths elided as <temp>:

=== UseSystemResourceKeys=false ===
UseSystemResourceKeys switch as the runtime sees it: False

### File.Delete on a file held with FileShare.None
  type    : System.IO.IOException
  HResult : 0x80070020  (win32 32)
  Message : The process cannot access the file '<temp>\unity-20260816-020316.log' because it is being used by another process.

### Write into a byte range locked by another handle
  type    : System.IO.IOException
  HResult : 0x80070021  (win32 33)
  Message : The process cannot access the file because another process has locked a portion of the file. : '<temp>\unity-20260816-020316.log'.

=== UseSystemResourceKeys=true ===
UseSystemResourceKeys switch as the runtime sees it: True

### File.Delete on a file held with FileShare.None
  type    : System.IO.IOException
  HResult : 0x80070020  (win32 32)
  Message : IO_SharingViolation_File, <temp>\unity-20260816-020316.log

### Write into a byte range locked by another handle
  type    : System.IO.IOException
  HResult : 0x80070021  (win32 33)
  Message : The process cannot access the file because another process has locked a portion of the file. : '<temp>\unity-20260816-020316.log'.

Three things to take from it. The sharing-violation message collapses to the key exactly as the setting advertises. The HResult is byte-identical across both builds, which is what makes it the correct thing to classify on. And the lock-violation message did not change at all, which the next section explains and which is the trap inside the trap.

Not every message is stripped, so "the message looked fine" proves nothing

Stripping happens per call site, not per exception type and not per assembly-wide sweep. Only messages that go through SR are affected. From dotnet/runtime, src/libraries/Common/src/System/IO/Win32Marshal.cs:

case Interop.Errors.ERROR_SHARING_VIOLATION:
    return new IOException(
        string.IsNullOrEmpty(path) ? SR.IO_SharingViolation_NoFileName : SR.Format(SR.IO_SharingViolation_File, path),
        MakeHRFromErrorCode(errorCode));
default:
    string msg = GetPInvokeErrorMessage(errorCode);
    if (!string.IsNullOrEmpty(path))
    {
        msg += $" : '{path}'.";
    }
    if (!string.IsNullOrEmpty(errorDetails))
    {
        msg += $" {errorDetails}";
    }

    return new IOException(msg, MakeHRFromErrorCode(errorCode));

static string GetPInvokeErrorMessage(int errorCode)
{
    // Call Kernel32.GetMessage directly in CoreLib. It eliminates one level of indirection and it is necessary to
    // produce correct error messages for CoreCLR Win32 PAL.
#if NET && !SYSTEM_PRIVATE_CORELIB
    return Marshal.GetPInvokeErrorMessage(errorCode);
#else
    return Interop.Kernel32.GetMessage(errorCode);
#endif
}

ERROR_SHARING_VIOLATION has a named case that formats a managed resource string, so UseSystemResourceKeys strips it. ERROR_LOCK_VIOLATION has no case; it falls to default, whose message comes from the operating system through FormatMessage, not from a .NET resource, so the setting cannot touch it. The trailing " : '<path>'." in the measured lock-violation output is Win32Marshal's own concatenation, visible on line three of that default block.

Two consequences:

  • A build with this setting on produces a MIX of English sentences and resource keys, and which one you get depends on which BCL call site threw. Do not infer the setting is off from one readable message.
  • The OS-sourced half is localised by Windows rather than by .NET, so it is not English on a non-English machine either. Both halves of the mix are unsafe to match on.

Also relevant: this page's own numbers assume the FACILITY_WIN32 mapping in the same file, which is where the 0x8007 prefix comes from:

internal static int MakeHRFromErrorCode(int errorCode)
{
    // Don't convert it if it is already an HRESULT
    if ((0xFFFF0000 & errorCode) != 0)
        return errorCode;

    return unchecked(((int)0x80070000) | errorCode);
}

Classify from HResult, never from message text

The two codes, from Microsoft's WinError.h reference, verbatim:

ERROR_SHARING_VIOLATION 32 (0x20) The process cannot access the file because it is being used by another process.

ERROR_LOCK_VIOLATION 33 (0x21) The process cannot access the file because another process has locked a portion of the file.

As HRESULTs via MakeHRFromErrorCode: 0x80070020 and 0x80070021. Both confirmed empirically in the measurement above, in both builds.

The rig's classifier, at ResetExecutor.cs:331-349, which is the shape to copy:

/// <summary>ERROR_SHARING_VIOLATION (32) or ERROR_LOCK_VIOLATION (33), as an HRESULT.</summary>
/// <remarks>
/// Read from <see cref="Exception.HResult"/> rather than from the message, because the
/// message is a stripped resource key in the shipped binary and is localised in any build
/// that keeps its resources. The text probe behind it is a fallback for a wrapped
/// exception that lost the code, never the primary test.
/// </remarks>
public static bool IsFileHeldOpen(Exception? error)
{
    if (error is null) return false;

    const int sharingViolation = unchecked((int)0x80070020);
    const int lockViolation = unchecked((int)0x80070021);

    if (error.HResult == sharingViolation || error.HResult == lockViolation) return true;

    return error.Message.Contains("IO_SharingViolation", StringComparison.OrdinalIgnoreCase)
           || error.Message.Contains("being used by another process", StringComparison.OrdinalIgnoreCase);
}

Note what the fallback is for. It is not a second opinion on the primary test; it exists for an exception that was wrapped and lost its code on the way up, and it deliberately probes both spellings (the resource key and the English sentence) because either can arrive from the same binary.

unchecked((int)0x8007....) is required: the constants exceed int.MaxValue and will not compile as a plain literal comparison against Exception.HResult, which is int.

What it costs, and when to keep it

The cost is paid by whoever reads the failure. IO_SharingViolation_File, C:\...\unity-...log teaches an operator nothing: it does not say the file is held, it does not say by what, and it does not suggest waiting. A tool that publishes with this setting owes its own explanation on the failure path, which is what the rig does by appending a plain-language hint once IsFileHeldOpen has identified the case from the HResult.

Keep the setting when binary size is a real constraint (it is one of the size levers that pairs with PublishAot and InvariantGlobalization), and pay for it with explicit messages at the boundaries that matter. Drop the line if nobody is counting bytes; nothing else in the build depends on it.

Reproducing this

Snapshot-style evidence does not survive, so the recipe does. A net10.0 console project with

<UseSystemResourceKeys Condition="'$(UseSystemResourceKeys)' == ''">false</UseSystemResourceKeys>

and this body, run twice with -p:UseSystemResourceKeys=false and =true:

var path = Path.Combine(dir, "held.log");
File.WriteAllText(path, "held");

Console.WriteLine(AppContext.TryGetSwitch("System.Resources.UseSystemResourceKeys", out var on)
    ? on.ToString() : "(switch not present)");

// sharing violation
using (var _ = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
{
    try { File.Delete(path); }
    catch (Exception ex) { Console.WriteLine($"0x{ex.HResult:X8} {ex.Message}"); }
}

// lock violation
using (var holder = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite))
{
    holder.Lock(0, 4);
    using var other = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite);
    try { other.Write([1, 2, 3, 4]); other.Flush(); }
    catch (Exception ex) { Console.WriteLine($"0x{ex.HResult:X8} {ex.Message}"); }
    holder.Unlock(0, 4);
}

Building this inside the monorepo needs an empty Directory.Build.props beside it, or MSBuild walks up to the repo root and demands $(StationeersPath).

Verification history

  • 2026-08-17, 0.2.6428.27798: page created. Every claim verified independently of the originating notes rather than carried across. The three csproj properties read from TestRig/src/TestRig.Cli/TestRig.Cli.csproj at 0803ed8e (lines 17, 19, 20). The setting's documented behaviour from Microsoft's trimming-options reference. The switch name, the key-only and key, args message shapes, the ERROR_SHARING_VIOLATION vs default split and MakeHRFromErrorCode from current dotnet/runtime source (Common/src/System/SR.cs, Common/src/System/IO/Win32Marshal.cs). The two Win32 codes from Microsoft's WinError.h reference. Opt-in status from the SDK on disk (Microsoft.NET.Sdk 10.0.400 and microsoft.net.illink.tasks 10.0.0). Both HResults and both message forms reproduced empirically on this machine with the program in "Reproducing this".
  • 2026-08-17: the lock-violation asymmetry (that UseSystemResourceKeys strips the sharing-violation message but leaves the lock-violation message intact) was found by the measurement, not inherited. The originating notes in TestRig/RESEARCH.md treat both codes as equivalent, which is correct for the classifier and wrong as a statement about the message text. Recorded here rather than as a contradiction, because the source made no claim about the lock-violation message either way.

Open questions

  • Whether any other BCL call site the rig can reach produces a stripped message the classifier does not have a text fallback for. Only the file-IO paths were enumerated; the setting applies to every System.* assembly, so process, socket and serialization messages are equally affected and were not surveyed.
  • Whether the Exception.HResult of a wrapped exception is preserved by every wrapper in the tool chain. The text fallback in IsFileHeldOpen exists because it was assumed not to be, but no case of a lost HResult has actually been observed here.