From 3c492aedc21faa13289f23d84724f0299524eb4d Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 18 Aug 2026 16:02:15 +0200 Subject: [PATCH] test: replace runtime root source freezes --- .../2026-08-18-r3-test-truth-ledger.md | 36 ++++ .../Architecture/CompiledCallGraph.cs | 50 +++++ .../GameWindowLiveSessionOwnershipTests.cs | 190 ++++++++--------- .../Rendering/GameWindowCrashStatusTests.cs | 192 ++++++++---------- 4 files changed, 248 insertions(+), 220 deletions(-) diff --git a/docs/reviews/2026-08-18-r3-test-truth-ledger.md b/docs/reviews/2026-08-18-r3-test-truth-ledger.md index e121dca2..80000609 100644 --- a/docs/reviews/2026-08-18-r3-test-truth-ledger.md +++ b/docs/reviews/2026-08-18-r3-test-truth-ledger.md @@ -1265,3 +1265,39 @@ Verification: attributed methods, and reduces direct/total source readers from 57/83 to 44/70. The remaining 70 reconcile to the 22 approved retained policies/contracts and 48 staged replacements. + +## Batch AA Runtime-root and crash-status source-freeze replacement + +Batch AA converts the three approved Runtime-root/session-host freezes and the +three crash-status freezes. No product source changes and no test is removed. + +The canonical-root checks now scan compiled App metadata rather than C# text: +`GameWindow` constructs exactly one `GameRuntime`, acquires its one host lease, +and constructs none of the displaced Runtime child roots; the entire App +assembly contains exactly one `LiveSessionCommandSurface` construction edge. +The character-creation status regression follows the compiled delegate targets +created by `LiveSessionRuntimeFactory.Create` and verifies the exact Guid/Name +and RawCode/Reason/AttemptedName property-to-writer call order. The existing +Runtime `SessionStatusWriter` suite remains the payload-shape oracle. + +The #406 crash regression now inspects the built `GameWindow.Run` and +`ReportExited` methods. It proves the native frame-loop call precedes cleanup +retention, the `_runFailure` store precedes rethrow, the failure read precedes +all terminal paths, the three crash/graceful/incomplete writer calls and their +literals retain their order, and both shutdown exits funnel through the one +reporting method. Reflection verifies the nullable exception latch exists and +that neither constructor assigns it, preserving its default-null state. +`CompiledCallGraph` gained a reusable field-load/store reader so latch ordering +can be checked by IL offset without pinning field expressions or formatting. + +Verification: + +- all 15 focused Runtime-root/session-host/crash-status methods pass; +- the complete locked Release build covers all 44 projects with zero warnings + and zero errors; +- the no-retry complete hermetic Release gate remains 14,346/14,346 with zero + skips or failures across all 12 test assemblies; and +- the regenerated 1,254-file inventory parses every file, remains at 11,414 + attributed methods, and reduces direct/total source readers from 44/70 to + 41/64. The remaining 64 reconcile to the 22 approved retained + policies/contracts and 42 staged replacements. diff --git a/tests/AcDream.App.Tests/Architecture/CompiledCallGraph.cs b/tests/AcDream.App.Tests/Architecture/CompiledCallGraph.cs index b82a3025..4228a501 100644 --- a/tests/AcDream.App.Tests/Architecture/CompiledCallGraph.cs +++ b/tests/AcDream.App.Tests/Architecture/CompiledCallGraph.cs @@ -5,6 +5,10 @@ namespace AcDream.App.Tests.Architecture; internal readonly record struct CompiledCall(int Offset, MethodBase Target); internal readonly record struct CompiledInstruction(int Offset, OpCode OpCode); +internal readonly record struct CompiledFieldReference( + int Offset, + OpCode OpCode, + FieldInfo Field); /// /// Reads compiled call/new-object edges from a method body. Architecture tests @@ -96,6 +100,52 @@ internal static class CompiledCallGraph return instructions; } + /// + /// Reads compiled field loads and stores with their instruction offsets. + /// This supports lifetime/state-order checks whose observable contract is + /// a latch edge rather than a source spelling. + /// + public static IReadOnlyList ReadFieldReferences( + MethodBase method) + { + ArgumentNullException.ThrowIfNull(method); + byte[] il = method.GetMethodBody()?.GetILAsByteArray() + ?? throw new InvalidOperationException( + $"{method.DeclaringType?.FullName}.{method.Name} has no compiled body."); + Type[]? declaringArguments = method.DeclaringType?.IsGenericType == true + ? method.DeclaringType.GetGenericArguments() + : null; + Type[]? methodArguments = method.IsGenericMethod + ? method.GetGenericArguments() + : null; + var references = new List(); + + for (int cursor = 0; cursor < il.Length;) + { + int instructionOffset = cursor; + OpCode opCode = ReadOpCode(il, ref cursor); + if (opCode.OperandType == OperandType.InlineField) + { + int token = BitConverter.ToInt32(il, cursor); + FieldInfo? field = method.Module.ResolveField( + token, + declaringArguments, + methodArguments); + if (field is not null) + { + references.Add(new CompiledFieldReference( + instructionOffset, + opCode, + field)); + } + } + + cursor += OperandSize(opCode.OperandType, il, cursor); + } + + return references; + } + /// /// Reads types named by compiled type operands such as casts, boxing, and /// isinst. This lets tests retain an exact type boundary without diff --git a/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs b/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs index a13977fc..947dfc16 100644 --- a/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs +++ b/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs @@ -1,6 +1,7 @@ using System.Reflection; -using AcDream.App.Rendering; using AcDream.App.Net; +using AcDream.App.Rendering; +using AcDream.App.Tests.Architecture; using AcDream.Core.Net; using AcDream.Runtime; using AcDream.Runtime.Session; @@ -61,43 +62,34 @@ public sealed class GameWindowLiveSessionOwnershipTests [Fact] public void ProductionWindowConstructsOnlyTheCanonicalRuntimeRoot() { - string root = FindRepositoryRoot(); - string source = File.ReadAllText(Path.Combine( - root, - "src", - "AcDream.App", - "Rendering", - "GameWindow.cs")); + IReadOnlyList calls = + CompiledCallGraph.ReadDeclared(typeof(GameWindow)); - Assert.Equal( - 1, - CountOccurrences(source, "new GameRuntime(")); - Assert.Contains( - "private readonly GameRuntime _runtime;", - source, - StringComparison.Ordinal); - Assert.Contains( - "_runtimeHostLease = _runtime.AcquireHostLease(", - source, - StringComparison.Ordinal); - string[] forbidden = + Assert.Single( + calls, + call => call.Target.DeclaringType == typeof(GameRuntime) + && call.Target.IsConstructor); + Assert.Single( + calls, + call => call.Target.DeclaringType == typeof(GameRuntime) + && call.Target.Name == nameof(GameRuntime.AcquireHostLease)); + HashSet forbiddenRuntimeRoots = [ - "new RuntimeEntityObjectLifetime(", - "new RuntimeInventoryState(", - "new RuntimeCharacterState(", - "new RuntimeCommunicationState(", - "new RuntimeActionState(", - "new RuntimeLocalPlayerMovementState(", - "new RuntimeWorldTransitState(", - "new LiveSessionController(", - "new GameRuntimeClock(", + "RuntimeEntityObjectLifetime", + "RuntimeInventoryState", + "RuntimeCharacterState", + "RuntimeCommunicationState", + "RuntimeActionState", + "RuntimeLocalPlayerMovementState", + "RuntimeWorldTransitState", + nameof(LiveSessionController), + "GameRuntimeClock", ]; - Assert.All( - forbidden, - value => Assert.DoesNotContain( - value, - source, - StringComparison.Ordinal)); + Assert.DoesNotContain( + calls, + call => call.Target.IsConstructor + && call.Target.DeclaringType is { } type + && forbiddenRuntimeRoots.Contains(type.Name)); } [Fact] @@ -110,14 +102,12 @@ public sealed class GameWindowLiveSessionOwnershipTests // gated command route. A second construction site anywhere under // src/AcDream.App would silently split that route into two, each // with its own activation/dispose lifecycle. - string root = FindRepositoryRoot(); - string appRoot = Path.Combine(root, "src", "AcDream.App"); - - int total = Directory - .EnumerateFiles(appRoot, "*.cs", SearchOption.AllDirectories) - .Sum(path => CountOccurrences( - File.ReadAllText(path), - "new LiveSessionCommandSurface(")); + int total = typeof(GameWindow).Assembly + .GetTypes() + .SelectMany(CompiledCallGraph.ReadDeclared) + .Count(call => call.Target.DeclaringType + == typeof(LiveSessionCommandSurface) + && call.Target.IsConstructor); Assert.Equal(1, total); } @@ -142,17 +132,11 @@ public sealed class GameWindowLiveSessionOwnershipTests /// (the App-layer wiring that forwards those two Runtime events to /// SessionStatusWriter, feeding the launcher's status-payload /// cycle) leaves every test suite green. LiveSessionRuntimeFactory - /// has exactly one production construction site - /// (SessionPlayerComposition.cs), buried inside the full - /// GameWindow composition graph, and no test in this repository - /// constructs it directly — there is no practical seam to exercise the - /// wiring behaviorally without a . This test - /// follows the SAME source-text-pin pattern the rest of this file - /// already uses for wiring that can't otherwise be unit-tested - /// (, - /// ): it fails if either - /// delegate assignment is removed or its argument mapping changes. The - /// exact PAYLOAD shape these calls must produce is pinned separately, + /// has exactly one production construction site, buried inside the full + /// GameWindow composition graph. The compiled delegate targets are + /// therefore the narrowest non-mutating seam for proving both bindings and + /// their argument maps without opening a real graphical session. The exact + /// payload shape these calls must produce is pinned separately, /// at SessionStatusWriterTests.CharacterCreatedAndCreationFailed_WriteThePinnedShape /// (tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs) /// — together the two tests cover "the delegates are bound" (here) and @@ -161,69 +145,57 @@ public sealed class GameWindowLiveSessionOwnershipTests [Fact] public void LiveSessionRuntimeFactoryBindsCharacterCreatedAndCreationFailedToTheStatusWriter() { - string root = FindRepositoryRoot(); - string source = File.ReadAllText(Path.Combine( - root, - "src", - "AcDream.App", - "Net", - "LiveSessionRuntimeFactory.cs")); + MethodInfo create = typeof(LiveSessionRuntimeFactory).GetMethod( + nameof(LiveSessionRuntimeFactory.Create))!; + MethodBase[] targets = CompiledCallGraph.ReadMethodReferences(create) + .Select(call => call.Target) + .Where(method => method.GetMethodBody() is not null) + .Distinct() + .ToArray(); + MethodBase created = Assert.Single( + targets, + target => CallsStatusWriter(target, nameof(SessionStatusWriter.CharacterCreated))); + MethodBase failed = Assert.Single( + targets, + target => CallsStatusWriter(target, nameof(SessionStatusWriter.CreationFailed))); Assert.Contains( - "CharacterCreated: identity => _statusWriter.CharacterCreated(", - source, - StringComparison.Ordinal); + created.GetParameters(), + parameter => parameter.ParameterType + == typeof(RuntimeCharacterCreationIdentity)); + AssertCallOrder( + created, + (typeof(RuntimeCharacterCreationIdentity), "get_Guid"), + (typeof(RuntimeCharacterCreationIdentity), "get_Name"), + (typeof(SessionStatusWriter), nameof(SessionStatusWriter.CharacterCreated))); Assert.Contains( - "identity.Guid,", - source, - StringComparison.Ordinal); - Assert.Contains( - "identity.Name),", - source, - StringComparison.Ordinal); - Assert.Contains( - "CreationFailed: rejection => _statusWriter.CreationFailed(", - source, - StringComparison.Ordinal); - Assert.Contains( - "rejection.RawCode,", - source, - StringComparison.Ordinal); - Assert.Contains( - "rejection.Reason,", - source, - StringComparison.Ordinal); - Assert.Contains( - "rejection.AttemptedName)),", - source, - StringComparison.Ordinal); + failed.GetParameters(), + parameter => parameter.ParameterType + == typeof(RuntimeCharacterCreationRejection)); + AssertCallOrder( + failed, + (typeof(RuntimeCharacterCreationRejection), "get_RawCode"), + (typeof(RuntimeCharacterCreationRejection), "get_Reason"), + (typeof(RuntimeCharacterCreationRejection), "get_AttemptedName"), + (typeof(SessionStatusWriter), nameof(SessionStatusWriter.CreationFailed))); } - private static int CountOccurrences(string source, string value) - { - int count = 0; - int cursor = 0; - while ((cursor = source.IndexOf( - value, - cursor, - StringComparison.Ordinal)) >= 0) - { - count++; - cursor += value.Length; - } - return count; - } + private static bool CallsStatusWriter(MethodBase method, string methodName) => + CompiledCallGraph.Read(method).Any(call => + call.Target.DeclaringType == typeof(SessionStatusWriter) + && call.Target.Name == methodName); - private static string FindRepositoryRoot() + private static void AssertCallOrder( + MethodBase method, + params (Type Type, string Method)[] expected) { - var current = new DirectoryInfo(AppContext.BaseDirectory); - while (current is not null) + IReadOnlyList calls = CompiledCallGraph.Read(method); + int cursor = -1; + foreach ((Type type, string name) in expected) { - if (File.Exists(Path.Combine(current.FullName, "AcDream.slnx"))) - return current.FullName; - current = current.Parent; + int found = CompiledCallGraph.IndexOf(calls, type, name, cursor + 1); + Assert.True(found > cursor, $"Missing compiled edge {type.FullName}.{name}."); + cursor = found; } - - throw new DirectoryNotFoundException("AcDream.slnx was not found."); } } diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowCrashStatusTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowCrashStatusTests.cs index 7436ae21..12b70ca3 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowCrashStatusTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowCrashStatusTests.cs @@ -1,139 +1,109 @@ +using System.Reflection; +using System.Reflection.Emit; +using AcDream.App.Rendering; +using AcDream.App.Tests.Architecture; +using AcDream.Runtime.Session; +using Silk.NET.Windowing; + namespace AcDream.App.Tests.Rendering; /// -/// Fix #406: before this change, GameWindow.CompleteShutdown wrote -/// a hardcoded exited{code:0,reason:"graceful"} status event -/// whenever the resource-shutdown transaction converged — even when -/// Dispose() (and therefore CompleteShutdown) ran mid-unwind -/// of an exception that escaped Run()'s Silk.NET frame loop and was -/// about to crash the process via the CLR's unhandled-exception path. -/// GameWindow cannot be constructed without a live GPU/window (see -/// the established pattern in GameWindowHostBoundaryTests), so -/// this pins the fix as a source-shape test exactly like that file does -/// for the surrounding shutdown machinery. +/// Fix #406: shutdown must not report a graceful exit while an exception from +/// the native frame loop is still unwinding. These checks inspect compiled +/// latch and status edges because constructing would +/// require a real GPU/window. /// public sealed class GameWindowCrashStatusTests { [Fact] public void Run_LatchesRunFailureBeforeRethrowingFromTheFrameLoopCatch() { - string body = MethodBody( - "public void Run()", - "void IGameWindowPlatformPublication.PublishGraphics("); - string tryBlock = Slice(body, "try\n {\n _window.Run();", "}\n }"); + MethodInfo run = RequiredMethod(nameof(GameWindow.Run)); + IReadOnlyList calls = CompiledCallGraph.Read(run); + CompiledCall frameLoop = Assert.Single( + calls, + call => call.Target.Name == nameof(IWindow.Run) + && call.Target.DeclaringType?.Namespace == "Silk.NET.Windowing"); + CompiledCall retain = Assert.Single( + calls, + call => call.Target.DeclaringType + == typeof(ResourceConstructionCleanupLedger) + && call.Target.Name + == nameof(ResourceConstructionCleanupLedger.RetainFrom)); + CompiledFieldReference latch = Assert.Single( + CompiledCallGraph.ReadFieldReferences(run), + field => field.Field.Name == "_runFailure" + && field.OpCode == OpCodes.Stfld); + CompiledInstruction rethrow = Assert.Single( + CompiledCallGraph.ReadInstructions(run), + instruction => instruction.OpCode == OpCodes.Rethrow); - AssertAppearsInOrder( - tryBlock, - "_window.Run();", - "catch (Exception failure)", - "_constructionCleanup.RetainFrom(failure);", - // The latch MUST happen before the rethrow: Dispose() (and - // therefore CompleteShutdown/ReportExited) can run mid-unwind - // of this exact exception, via Program.cs's - // `using var window = ...`. - "_runFailure = failure;", - "throw;"); + Assert.True(frameLoop.Offset < retain.Offset); + Assert.True(retain.Offset < latch.Offset); + Assert.True(latch.Offset < rethrow.Offset); } [Fact] public void ReportExited_ChecksRunFailureBeforeEitherGracefulOrShutdownIncompletePaths() { - string source = GameWindowSource(); - string reportExited = Slice( - source, - "private void ReportExited(GameWindowLifetimeReport report)", - "\n }\n"); + MethodInfo reportExited = RequiredMethod("ReportExited"); + IReadOnlyList calls = CompiledCallGraph.Read(reportExited); + CompiledFieldReference failureRead = Assert.Single( + CompiledCallGraph.ReadFieldReferences(reportExited), + field => field.Field.Name == "_runFailure" + && field.OpCode == OpCodes.Ldfld); + CompiledCall[] exited = calls + .Where(call => call.Target.DeclaringType == typeof(SessionStatusWriter) + && call.Target.Name == nameof(SessionStatusWriter.Exited)) + .ToArray(); + Assert.Equal(3, exited.Length); + CompiledCall status = Assert.Single( + calls, + call => call.Target.DeclaringType == typeof(GameWindowLifetimeReport) + && call.Target.Name == "get_Status"); - Assert.Contains( - "string sessionId = _options.SessionId ?? \"app\";", - reportExited, - StringComparison.Ordinal); - AssertAppearsInOrder( - reportExited, - "if (_runFailure is not null)", - "_statusWriter.Exited(sessionId, 1, \"crashed\");", - "return;", - "if (report.Status == GameWindowLifetimeStatus.Complete)", - "_statusWriter.Exited(sessionId, 0, \"graceful\");", - "_statusWriter.Exited(sessionId, 1, \"shutdown-incomplete\");"); + Assert.True(failureRead.Offset < exited[0].Offset); + Assert.True(exited[0].Offset < status.Offset); + Assert.True(status.Offset < exited[1].Offset); + Assert.True(exited[1].Offset < exited[2].Offset); + Assert.Equal( + ["app", "crashed", "graceful", "shutdown-incomplete"], + CompiledCallGraph.ReadStringLiterals(reportExited)); - // Every terminal-status write in CompleteShutdown funnels through - // this ONE method — a second, uncoordinated call site would be - // exactly how the pre-fix bug reappears. + MethodInfo completeShutdown = RequiredMethod("CompleteShutdown"); Assert.Equal( 2, - CountOccurrences(source, "ReportExited(report)")); - Assert.DoesNotContain( - "_statusWriter.Exited(_options.SessionId ?? \"app\", 0, \"graceful\")", - source, - StringComparison.Ordinal); + CompiledCallGraph.Read(completeShutdown).Count(call => + call.Target.DeclaringType == typeof(GameWindow) + && call.Target.Name == "ReportExited")); + Assert.Equal( + exited.Length, + CompiledCallGraph.ReadDeclared(typeof(GameWindow)).Count(call => + call.Target.DeclaringType == typeof(SessionStatusWriter) + && call.Target.Name == nameof(SessionStatusWriter.Exited))); } [Fact] public void RunFailureFieldExistsAndDefaultsToNull() { - string source = GameWindowSource(); + FieldInfo field = typeof(GameWindow).GetField( + "_runFailure", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingFieldException(typeof(GameWindow).FullName, "_runFailure"); + Assert.Equal(typeof(Exception), field.FieldType); - Assert.Contains( - "private Exception? _runFailure;", - source, - StringComparison.Ordinal); + ConstructorInfo[] constructors = typeof(GameWindow).GetConstructors( + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + Assert.NotEmpty(constructors); + Assert.DoesNotContain( + constructors.SelectMany(CompiledCallGraph.ReadFieldReferences), + reference => reference.Field == field + && reference.OpCode == OpCodes.Stfld); } - private static string MethodBody(string start, string end) => - Slice(GameWindowSource(), start, end); - - private static string Slice(string source, string start, string end) - { - int first = source.IndexOf(start, StringComparison.Ordinal); - int last = source.IndexOf(end, first + 1, StringComparison.Ordinal); - Assert.True(first >= 0, $"Missing source boundary: {start}"); - Assert.True(last > first, $"Missing source boundary: {end}"); - return source[first..last]; - } - - private static int CountOccurrences(string source, string value) - { - int count = 0; - int cursor = 0; - while ((cursor = source.IndexOf(value, cursor, StringComparison.Ordinal)) >= 0) - { - count++; - cursor += value.Length; - } - - return count; - } - - private static void AssertAppearsInOrder(string source, params string[] fragments) - { - int cursor = -1; - foreach (string fragment in fragments) - { - int next = source.IndexOf(fragment, cursor + 1, StringComparison.Ordinal); - Assert.True(next >= 0, $"Missing expected source fragment: {fragment}"); - Assert.True(next > cursor, $"Out-of-order source fragment: {fragment}"); - cursor = next; - } - } - - private static string GameWindowSource() => File.ReadAllText(Path.Combine( - FindRepoRoot(), - "src", - "AcDream.App", - "Rendering", - "GameWindow.cs")).Replace("\r\n", "\n", StringComparison.Ordinal); - - private static string FindRepoRoot() - { - DirectoryInfo? directory = new(AppContext.BaseDirectory); - while (directory is not null) - { - if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) - return directory.FullName; - directory = directory.Parent; - } - - throw new DirectoryNotFoundException("Could not find AcDream.slnx."); - } + private static MethodInfo RequiredMethod(string name) => + typeof(GameWindow).GetMethod( + name, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?? throw new MissingMethodException(typeof(GameWindow).FullName, name); }