test: replace runtime root source freezes

This commit is contained in:
Erik 2026-08-18 16:02:15 +02:00
parent 80c7b44457
commit 3c492aedc2
4 changed files with 248 additions and 220 deletions

View file

@ -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.

View file

@ -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);
/// <summary>
/// Reads compiled call/new-object edges from a method body. Architecture tests
@ -96,6 +100,52 @@ internal static class CompiledCallGraph
return instructions;
}
/// <summary>
/// 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.
/// </summary>
public static IReadOnlyList<CompiledFieldReference> 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<CompiledFieldReference>();
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;
}
/// <summary>
/// Reads types named by compiled type operands such as casts, boxing, and
/// <c>isinst</c>. This lets tests retain an exact type boundary without

View file

@ -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<CompiledCall> 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<string> 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
/// <c>SessionStatusWriter</c>, feeding the launcher's status-payload
/// cycle) leaves every test suite green. <c>LiveSessionRuntimeFactory</c>
/// has exactly one production construction site
/// (<c>SessionPlayerComposition.cs</c>), buried inside the full
/// <c>GameWindow</c> composition graph, and no test in this repository
/// constructs it directly — there is no practical seam to exercise the
/// wiring behaviorally without a <see cref="GameWindow"/>. 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
/// (<see cref="ProductionWindowConstructsOnlyTheCanonicalRuntimeRoot"/>,
/// <see cref="DisplacedLifecycleBodiesAreAbsent"/>): 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
/// <c>GameWindow</c> 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 <c>SessionStatusWriterTests.CharacterCreatedAndCreationFailed_WriteThePinnedShape</c>
/// (<c>tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs</c>)
/// — 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<CompiledCall> 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.");
}
}

View file

@ -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;
/// <summary>
/// Fix #406: before this change, <c>GameWindow.CompleteShutdown</c> wrote
/// a hardcoded <c>exited{code:0,reason:"graceful"}</c> status event
/// whenever the resource-shutdown transaction converged — even when
/// <c>Dispose()</c> (and therefore <c>CompleteShutdown</c>) ran mid-unwind
/// of an exception that escaped <c>Run()</c>'s Silk.NET frame loop and was
/// about to crash the process via the CLR's unhandled-exception path.
/// <c>GameWindow</c> cannot be constructed without a live GPU/window (see
/// the established pattern in <c>GameWindowHostBoundaryTests</c>), 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 <see cref="GameWindow"/> would
/// require a real GPU/window.
/// </summary>
public sealed class GameWindowCrashStatusTests
{
[Fact]
public void Run_LatchesRunFailureBeforeRethrowingFromTheFrameLoopCatch()
{
string body = MethodBody(
"public void Run()",
"void IGameWindowPlatformPublication<GameWindowGraphics, IInputContext>.PublishGraphics(");
string tryBlock = Slice(body, "try\n {\n _window.Run();", "}\n }");
MethodInfo run = RequiredMethod(nameof(GameWindow.Run));
IReadOnlyList<CompiledCall> 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<CompiledCall> 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);
}