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 attributed methods, and reduces direct/total source readers from 57/83 to
44/70. The remaining 70 reconcile to the 22 approved retained 44/70. The remaining 70 reconcile to the 22 approved retained
policies/contracts and 48 staged replacements. 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 CompiledCall(int Offset, MethodBase Target);
internal readonly record struct CompiledInstruction(int Offset, OpCode OpCode); internal readonly record struct CompiledInstruction(int Offset, OpCode OpCode);
internal readonly record struct CompiledFieldReference(
int Offset,
OpCode OpCode,
FieldInfo Field);
/// <summary> /// <summary>
/// Reads compiled call/new-object edges from a method body. Architecture tests /// Reads compiled call/new-object edges from a method body. Architecture tests
@ -96,6 +100,52 @@ internal static class CompiledCallGraph
return instructions; 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> /// <summary>
/// Reads types named by compiled type operands such as casts, boxing, and /// Reads types named by compiled type operands such as casts, boxing, and
/// <c>isinst</c>. This lets tests retain an exact type boundary without /// <c>isinst</c>. This lets tests retain an exact type boundary without

View file

@ -1,6 +1,7 @@
using System.Reflection; using System.Reflection;
using AcDream.App.Rendering;
using AcDream.App.Net; using AcDream.App.Net;
using AcDream.App.Rendering;
using AcDream.App.Tests.Architecture;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Session; using AcDream.Runtime.Session;
@ -61,43 +62,34 @@ public sealed class GameWindowLiveSessionOwnershipTests
[Fact] [Fact]
public void ProductionWindowConstructsOnlyTheCanonicalRuntimeRoot() public void ProductionWindowConstructsOnlyTheCanonicalRuntimeRoot()
{ {
string root = FindRepositoryRoot(); IReadOnlyList<CompiledCall> calls =
string source = File.ReadAllText(Path.Combine( CompiledCallGraph.ReadDeclared(typeof(GameWindow));
root,
"src",
"AcDream.App",
"Rendering",
"GameWindow.cs"));
Assert.Equal( Assert.Single(
1, calls,
CountOccurrences(source, "new GameRuntime(")); call => call.Target.DeclaringType == typeof(GameRuntime)
Assert.Contains( && call.Target.IsConstructor);
"private readonly GameRuntime _runtime;", Assert.Single(
source, calls,
StringComparison.Ordinal); call => call.Target.DeclaringType == typeof(GameRuntime)
Assert.Contains( && call.Target.Name == nameof(GameRuntime.AcquireHostLease));
"_runtimeHostLease = _runtime.AcquireHostLease(", HashSet<string> forbiddenRuntimeRoots =
source,
StringComparison.Ordinal);
string[] forbidden =
[ [
"new RuntimeEntityObjectLifetime(", "RuntimeEntityObjectLifetime",
"new RuntimeInventoryState(", "RuntimeInventoryState",
"new RuntimeCharacterState(", "RuntimeCharacterState",
"new RuntimeCommunicationState(", "RuntimeCommunicationState",
"new RuntimeActionState(", "RuntimeActionState",
"new RuntimeLocalPlayerMovementState(", "RuntimeLocalPlayerMovementState",
"new RuntimeWorldTransitState(", "RuntimeWorldTransitState",
"new LiveSessionController(", nameof(LiveSessionController),
"new GameRuntimeClock(", "GameRuntimeClock",
]; ];
Assert.All( Assert.DoesNotContain(
forbidden, calls,
value => Assert.DoesNotContain( call => call.Target.IsConstructor
value, && call.Target.DeclaringType is { } type
source, && forbiddenRuntimeRoots.Contains(type.Name));
StringComparison.Ordinal));
} }
[Fact] [Fact]
@ -110,14 +102,12 @@ public sealed class GameWindowLiveSessionOwnershipTests
// gated command route. A second construction site anywhere under // gated command route. A second construction site anywhere under
// src/AcDream.App would silently split that route into two, each // src/AcDream.App would silently split that route into two, each
// with its own activation/dispose lifecycle. // with its own activation/dispose lifecycle.
string root = FindRepositoryRoot(); int total = typeof(GameWindow).Assembly
string appRoot = Path.Combine(root, "src", "AcDream.App"); .GetTypes()
.SelectMany(CompiledCallGraph.ReadDeclared)
int total = Directory .Count(call => call.Target.DeclaringType
.EnumerateFiles(appRoot, "*.cs", SearchOption.AllDirectories) == typeof(LiveSessionCommandSurface)
.Sum(path => CountOccurrences( && call.Target.IsConstructor);
File.ReadAllText(path),
"new LiveSessionCommandSurface("));
Assert.Equal(1, total); Assert.Equal(1, total);
} }
@ -142,17 +132,11 @@ public sealed class GameWindowLiveSessionOwnershipTests
/// (the App-layer wiring that forwards those two Runtime events to /// (the App-layer wiring that forwards those two Runtime events to
/// <c>SessionStatusWriter</c>, feeding the launcher's status-payload /// <c>SessionStatusWriter</c>, feeding the launcher's status-payload
/// cycle) leaves every test suite green. <c>LiveSessionRuntimeFactory</c> /// cycle) leaves every test suite green. <c>LiveSessionRuntimeFactory</c>
/// has exactly one production construction site /// has exactly one production construction site, buried inside the full
/// (<c>SessionPlayerComposition.cs</c>), buried inside the full /// <c>GameWindow</c> composition graph. The compiled delegate targets are
/// <c>GameWindow</c> composition graph, and no test in this repository /// therefore the narrowest non-mutating seam for proving both bindings and
/// constructs it directly — there is no practical seam to exercise the /// their argument maps without opening a real graphical session. The exact
/// wiring behaviorally without a <see cref="GameWindow"/>. This test /// payload shape these calls must produce is pinned separately,
/// 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,
/// at <c>SessionStatusWriterTests.CharacterCreatedAndCreationFailed_WriteThePinnedShape</c> /// at <c>SessionStatusWriterTests.CharacterCreatedAndCreationFailed_WriteThePinnedShape</c>
/// (<c>tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs</c>) /// (<c>tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs</c>)
/// — together the two tests cover "the delegates are bound" (here) and /// — together the two tests cover "the delegates are bound" (here) and
@ -161,69 +145,57 @@ public sealed class GameWindowLiveSessionOwnershipTests
[Fact] [Fact]
public void LiveSessionRuntimeFactoryBindsCharacterCreatedAndCreationFailedToTheStatusWriter() public void LiveSessionRuntimeFactoryBindsCharacterCreatedAndCreationFailedToTheStatusWriter()
{ {
string root = FindRepositoryRoot(); MethodInfo create = typeof(LiveSessionRuntimeFactory).GetMethod(
string source = File.ReadAllText(Path.Combine( nameof(LiveSessionRuntimeFactory.Create))!;
root, MethodBase[] targets = CompiledCallGraph.ReadMethodReferences(create)
"src", .Select(call => call.Target)
"AcDream.App", .Where(method => method.GetMethodBody() is not null)
"Net", .Distinct()
"LiveSessionRuntimeFactory.cs")); .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( Assert.Contains(
"CharacterCreated: identity => _statusWriter.CharacterCreated(", created.GetParameters(),
source, parameter => parameter.ParameterType
StringComparison.Ordinal); == typeof(RuntimeCharacterCreationIdentity));
AssertCallOrder(
created,
(typeof(RuntimeCharacterCreationIdentity), "get_Guid"),
(typeof(RuntimeCharacterCreationIdentity), "get_Name"),
(typeof(SessionStatusWriter), nameof(SessionStatusWriter.CharacterCreated)));
Assert.Contains( Assert.Contains(
"identity.Guid,", failed.GetParameters(),
source, parameter => parameter.ParameterType
StringComparison.Ordinal); == typeof(RuntimeCharacterCreationRejection));
Assert.Contains( AssertCallOrder(
"identity.Name),", failed,
source, (typeof(RuntimeCharacterCreationRejection), "get_RawCode"),
StringComparison.Ordinal); (typeof(RuntimeCharacterCreationRejection), "get_Reason"),
Assert.Contains( (typeof(RuntimeCharacterCreationRejection), "get_AttemptedName"),
"CreationFailed: rejection => _statusWriter.CreationFailed(", (typeof(SessionStatusWriter), nameof(SessionStatusWriter.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);
} }
private static int CountOccurrences(string source, string value) private static bool CallsStatusWriter(MethodBase method, string methodName) =>
{ CompiledCallGraph.Read(method).Any(call =>
int count = 0; call.Target.DeclaringType == typeof(SessionStatusWriter)
int cursor = 0; && call.Target.Name == methodName);
while ((cursor = source.IndexOf(
value,
cursor,
StringComparison.Ordinal)) >= 0)
{
count++;
cursor += value.Length;
}
return count;
}
private static string FindRepositoryRoot() private static void AssertCallOrder(
MethodBase method,
params (Type Type, string Method)[] expected)
{ {
var current = new DirectoryInfo(AppContext.BaseDirectory); IReadOnlyList<CompiledCall> calls = CompiledCallGraph.Read(method);
while (current is not null) int cursor = -1;
foreach ((Type type, string name) in expected)
{ {
if (File.Exists(Path.Combine(current.FullName, "AcDream.slnx"))) int found = CompiledCallGraph.IndexOf(calls, type, name, cursor + 1);
return current.FullName; Assert.True(found > cursor, $"Missing compiled edge {type.FullName}.{name}.");
current = current.Parent; 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; namespace AcDream.App.Tests.Rendering;
/// <summary> /// <summary>
/// Fix #406: before this change, <c>GameWindow.CompleteShutdown</c> wrote /// Fix #406: shutdown must not report a graceful exit while an exception from
/// a hardcoded <c>exited{code:0,reason:"graceful"}</c> status event /// the native frame loop is still unwinding. These checks inspect compiled
/// whenever the resource-shutdown transaction converged — even when /// latch and status edges because constructing <see cref="GameWindow"/> would
/// <c>Dispose()</c> (and therefore <c>CompleteShutdown</c>) ran mid-unwind /// require a real GPU/window.
/// 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.
/// </summary> /// </summary>
public sealed class GameWindowCrashStatusTests public sealed class GameWindowCrashStatusTests
{ {
[Fact] [Fact]
public void Run_LatchesRunFailureBeforeRethrowingFromTheFrameLoopCatch() public void Run_LatchesRunFailureBeforeRethrowingFromTheFrameLoopCatch()
{ {
string body = MethodBody( MethodInfo run = RequiredMethod(nameof(GameWindow.Run));
"public void Run()", IReadOnlyList<CompiledCall> calls = CompiledCallGraph.Read(run);
"void IGameWindowPlatformPublication<GameWindowGraphics, IInputContext>.PublishGraphics("); CompiledCall frameLoop = Assert.Single(
string tryBlock = Slice(body, "try\n {\n _window.Run();", "}\n }"); 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( Assert.True(frameLoop.Offset < retain.Offset);
tryBlock, Assert.True(retain.Offset < latch.Offset);
"_window.Run();", Assert.True(latch.Offset < rethrow.Offset);
"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;");
} }
[Fact] [Fact]
public void ReportExited_ChecksRunFailureBeforeEitherGracefulOrShutdownIncompletePaths() public void ReportExited_ChecksRunFailureBeforeEitherGracefulOrShutdownIncompletePaths()
{ {
string source = GameWindowSource(); MethodInfo reportExited = RequiredMethod("ReportExited");
string reportExited = Slice( IReadOnlyList<CompiledCall> calls = CompiledCallGraph.Read(reportExited);
source, CompiledFieldReference failureRead = Assert.Single(
"private void ReportExited(GameWindowLifetimeReport report)", CompiledCallGraph.ReadFieldReferences(reportExited),
"\n }\n"); 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( Assert.True(failureRead.Offset < exited[0].Offset);
"string sessionId = _options.SessionId ?? \"app\";", Assert.True(exited[0].Offset < status.Offset);
reportExited, Assert.True(status.Offset < exited[1].Offset);
StringComparison.Ordinal); Assert.True(exited[1].Offset < exited[2].Offset);
AssertAppearsInOrder( Assert.Equal(
reportExited, ["app", "crashed", "graceful", "shutdown-incomplete"],
"if (_runFailure is not null)", CompiledCallGraph.ReadStringLiterals(reportExited));
"_statusWriter.Exited(sessionId, 1, \"crashed\");",
"return;",
"if (report.Status == GameWindowLifetimeStatus.Complete)",
"_statusWriter.Exited(sessionId, 0, \"graceful\");",
"_statusWriter.Exited(sessionId, 1, \"shutdown-incomplete\");");
// Every terminal-status write in CompleteShutdown funnels through MethodInfo completeShutdown = RequiredMethod("CompleteShutdown");
// this ONE method — a second, uncoordinated call site would be
// exactly how the pre-fix bug reappears.
Assert.Equal( Assert.Equal(
2, 2,
CountOccurrences(source, "ReportExited(report)")); CompiledCallGraph.Read(completeShutdown).Count(call =>
Assert.DoesNotContain( call.Target.DeclaringType == typeof(GameWindow)
"_statusWriter.Exited(_options.SessionId ?? \"app\", 0, \"graceful\")", && call.Target.Name == "ReportExited"));
source, Assert.Equal(
StringComparison.Ordinal); exited.Length,
CompiledCallGraph.ReadDeclared(typeof(GameWindow)).Count(call =>
call.Target.DeclaringType == typeof(SessionStatusWriter)
&& call.Target.Name == nameof(SessionStatusWriter.Exited)));
} }
[Fact] [Fact]
public void RunFailureFieldExistsAndDefaultsToNull() 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( ConstructorInfo[] constructors = typeof(GameWindow).GetConstructors(
"private Exception? _runFailure;", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
source, Assert.NotEmpty(constructors);
StringComparison.Ordinal); Assert.DoesNotContain(
constructors.SelectMany(CompiledCallGraph.ReadFieldReferences),
reference => reference.Field == field
&& reference.OpCode == OpCodes.Stfld);
} }
private static string MethodBody(string start, string end) => private static MethodInfo RequiredMethod(string name) =>
Slice(GameWindowSource(), start, end); typeof(GameWindow).GetMethod(
name,
private static string Slice(string source, string start, string end) BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
{ ?? throw new MissingMethodException(typeof(GameWindow).FullName, name);
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.");
}
} }