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"); + } +}