From 377cfe9c22cb595fa18df9c69ae1130513e9841a Mon Sep 17 00:00:00 2001 From: Alexey Nikitin Date: Fri, 7 Aug 2026 05:10:28 -0500 Subject: [PATCH] Keep the static field dump alive when a type initializer fails The Print static fields debug action produced no output at all when any loaded assembly had a static field whose declaring type's initializer throws. Reading a static field runs that initializer, which is arbitrary mod code, and FieldValue called field.GetValue(null) unguarded, so the exception unwound out of the action and Dialog_Debug reported a window failure instead of the listing. The action exists to surface unsynchronized mutable static state, which is a common cause of desyncs, so it is reached for precisely when something is already wrong and odd inputs are most likely. TryReadStaticValue now catches per field and reports the base exception's type name in place of the value; the outer TypeInitializationException names the mechanism and hides the cause. TypesOf falls back to the non-null entries of ReflectionTypeLoadException.Types, so an assembly referencing something absent still dumps the types it did resolve. Catching Exception rather than a specific type is deliberate: the thrown type is whatever a mod's initializer happens to throw, and GetValue can fail without an initializer being involved. Catching too much costs one line reading [unreadable: X]; catching too little costs the entire report. The two fragile reflection calls live in Common because Source/Tests cannot reference Client, which needs the game's assemblies to load. No behaviour beyond the guards changed. --- Source/Client/Debug/DebugActions.cs | 7 ++- Source/Common/StaticFieldDump.cs | 70 +++++++++++++++++++++++++++++ Source/Tests/StaticFieldDumpTest.cs | 60 +++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 Source/Common/StaticFieldDump.cs create mode 100644 Source/Tests/StaticFieldDumpTest.cs diff --git a/Source/Client/Debug/DebugActions.cs b/Source/Client/Debug/DebugActions.cs index 46d05147b..c084fb693 100644 --- a/Source/Client/Debug/DebugActions.cs +++ b/Source/Client/Debug/DebugActions.cs @@ -11,6 +11,7 @@ using HarmonyLib; using LudeonTK; using Multiplayer.Client.Desyncs; +using Multiplayer.Common; using Multiplayer.Client.Util; using Multiplayer.Client.Windows; using RimWorld; @@ -456,7 +457,9 @@ static string StaticFieldsToString(Assembly asm, Predicate typeValidator) object FieldValue(FieldInfo field) { - var value = field.GetValue(null); + if (!StaticFieldDump.TryReadStaticValue(field, out var value, out var failure)) + return $"[unreadable: {failure}]"; + if (value is ICollection col) return col.Count; if (field.Name.ToLowerInvariant().Contains("path") && value is string path && (path.Contains("/") || path.Contains("\\"))) @@ -464,7 +467,7 @@ object FieldValue(FieldInfo field) return value; } - foreach (var type in asm.GetTypes()) + foreach (var type in StaticFieldDump.TypesOf(asm)) if (!type.IsGenericTypeDefinition && type.Namespace != null && typeValidator(type) && !type.HasAttribute() && !type.HasAttribute()) foreach (var field in type.GetFields(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.DeclaredOnly)) if (!field.IsLiteral && !field.IsInitOnly && !field.HasAttribute()) diff --git a/Source/Common/StaticFieldDump.cs b/Source/Common/StaticFieldDump.cs new file mode 100644 index 000000000..9208cf079 --- /dev/null +++ b/Source/Common/StaticFieldDump.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Multiplayer.Common +{ + /// + /// The reflection behind the "Print static fields" debug dump, which lists mutable static state across + /// loaded assemblies. Unsynchronized static state is a common source of desyncs, so the dump is one of + /// the first things reached for when a session diverges. + /// + /// Kept here rather than beside the debug action so it can be tested. The test project cannot + /// reference the client, which needs the game's own assemblies to load. + /// + public static class StaticFieldDump + { + /// + /// Reads a static field's current value. + /// + /// Contract: never propagate. Reading a static field runs its declaring type's initializer, which + /// is arbitrary code and can fail for reasons that have nothing to do with the dump. A caller + /// wants the other few thousand fields even when one is unreadable. + /// + /// MonoMod is the case that forced this: it ships every platform's interop types in one assembly, + /// so the ones belonging to another operating system throw DllNotFoundException the moment they + /// are touched. That is true on every platform -- only the type names change. + /// + /// The static field to read. + /// The value read, or null when it could not be read. + /// A short description of why it could not be read, or null on success. + /// Whether the value was read. + public static bool TryReadStaticValue(FieldInfo field, out object value, out string failure) + { + try + { + value = field.GetValue(null); + failure = null; + return true; + } + catch (Exception e) + { + // The base exception, because the interesting part is what the initializer actually hit. + // Reporting TypeInitializationException would name the mechanism and hide the cause. + value = null; + failure = e.GetBaseException().GetType().Name; + return false; + } + } + + /// + /// The types in . + /// + /// Contract: report whatever loaded. An assembly referencing something absent cannot enumerate + /// all of its types, but it still resolves most of them, and those are worth dumping. + /// + public static IEnumerable TypesOf(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException e) + { + // Types is padded with nulls for the entries that failed to load. + return e.Types.Where(t => t != null); + } + } + } +} diff --git a/Source/Tests/StaticFieldDumpTest.cs b/Source/Tests/StaticFieldDumpTest.cs new file mode 100644 index 000000000..d5523abaf --- /dev/null +++ b/Source/Tests/StaticFieldDumpTest.cs @@ -0,0 +1,60 @@ +using System; +using System.Reflection; +using Multiplayer.Common; + +namespace Tests; + +public class StaticFieldDumpTest +{ + /// + /// Stands in for the platform-specific interop types MonoMod ships in a single assembly. Reading any + /// static on the ones belonging to a different operating system runs an initializer that P/Invokes a + /// library the host does not have. The shape is identical on every platform; only the type names + /// differ, so this reproduces it without depending on which OS the tests run under. + /// + /// The explicit static constructor is deliberate: it suppresses beforefieldinit, so initialization + /// happens exactly when the field is read rather than at some earlier point of the runtime's choosing. + /// + private static class FailsToInitialize + { + public static int Value; + + static FailsToInitialize() => throw new DllNotFoundException("libc"); + } + + private static class Ordinary + { + public static int Value; + + static Ordinary() => Value = 42; + } + + private static FieldInfo StaticFieldOf(Type type, string name) + => type.GetField(name, BindingFlags.Public | BindingFlags.Static); + + [Test] + public void TryReadStaticValue_ReadsAnOrdinaryField() + { + var read = StaticFieldDump.TryReadStaticValue( + StaticFieldOf(typeof(Ordinary), nameof(Ordinary.Value)), + out var value, + out var failure); + + Assert.That(read, Is.True); + Assert.That(value, Is.EqualTo(42)); + Assert.That(failure, Is.Null); + } + + [Test] + public void TryReadStaticValue_ReportsAFailingInitializerInsteadOfPropagating() + { + var read = StaticFieldDump.TryReadStaticValue( + StaticFieldOf(typeof(FailsToInitialize), nameof(FailsToInitialize.Value)), + out _, + out var failure); + + Assert.That(read, Is.False, "an unreadable field must be reported, not thrown"); + Assert.That(failure, Does.Contain(nameof(DllNotFoundException)), + "the report should name the underlying cause, not the TypeInitializationException wrapping it"); + } +}