acdream/tests/AcDream.App.Tests/Rendering/GameWindowLifetimeTests.cs
Erik 6c6664a685
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
fix(app): #343 — a wounded render loop defers the native release instead of throwing over the real failure
Root cause pinned by IL-decompiling Silk.NET.Windowing.Common:
ViewImplementationBase._inRenderLoop is set at DoRender/DoUpdate entry
and cleared ONLY on normal return, so a throwing frame callback leaves
it armed forever and any later Dispose -> Reset throws "You cannot call
Reset inside of the render loop", exit 82, replacing the original
wounding exception in the report.

The fix mirrors Silk's own bracket exactly: GameWindow._renderLoopArmed
set at OnUpdate/OnRender entry, cleared only on their normal return —
deliberately NOT in a finally, so it tracks the wound the same way
Silk's private field does. ReleaseNativeWindow checks it before
disposing: armed -> best-effort Close() (swallowed so it can never
become the reported failure), no Dispose, and a new terminal status
CompleteWithDeferredNativeRelease with Error kept null — the original
exception stays the primary report. Healthy paths (OnClosing's
in-loop completion, Run()'s tail release) are byte-unchanged, and the
new PublishNativeWindow parameters default to null so every existing
caller and test behaves identically.

Sabotage: disabling the armed-check flipped the deferral test to
Expected CompleteWithDeferredNativeRelease / Actual Complete —
the guard is what the test exercises. Clean-room suite 11,262 / 6 / 1,
the 1 being #340's documented load flake (passed standalone; second
recorded firing noted in its entry).

Queue: #344 done, #343 done; next #345's instrumented mechanism
session, then #341's boundary hunt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:28:29 +02:00

287 lines
11 KiB
C#

using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class GameWindowLifetimeTests
{
[Fact]
public void CleanCompletionReleasesNativeWindowLastAndTerminalCallsAreInert()
{
var calls = new List<string>();
var transaction = new ResourceShutdownTransaction(
new ResourceShutdownStage("owners",
[
new("owner", () => calls.Add("owner")),
]));
var lifetime = new GameWindowLifetime(() => transaction);
var native = new RecordingDisposable("native", calls);
lifetime.PublishNativeWindow(native);
GameWindowLifetimeReport closing = lifetime.TryComplete();
Assert.Equal(GameWindowLifetimeStatus.Complete, closing.Status);
Assert.Equal(["owner"], calls);
GameWindowLifetimeReport disposed = lifetime.CompleteAndReleaseNativeWindow();
GameWindowLifetimeReport repeated = lifetime.CompleteAndReleaseNativeWindow();
Assert.Same(closing, disposed);
Assert.Same(disposed, repeated);
Assert.Equal(["owner", "native"], calls);
Assert.Equal(1, native.DisposeCalls);
Assert.True(lifetime.HasShutdownRoots);
Assert.False(lifetime.RetainsShutdownGraph);
}
[Fact]
public void ArmedRenderLoopDefersNativeReleaseInsteadOfDisposing()
{
// #343: a render/update frame callback that threw leaves Silk's
// internal render-loop guard stuck armed. Disposing the native
// window into that state throws "You cannot call `Reset` inside of
// the render loop!" and buries whatever exception actually wounded
// the loop. GameWindowLifetime must detect the still-armed signal,
// request a close instead of disposing, and record a distinct
// deferred status rather than AbandonedIncomplete-with-throw.
var calls = new List<string>();
var transaction = new ResourceShutdownTransaction(
new ResourceShutdownStage("owners",
[
new("owner", () => calls.Add("owner")),
]));
var lifetime = new GameWindowLifetime(() => transaction);
var native = new RecordingDisposable("native", calls);
bool closeRequested = false;
lifetime.PublishNativeWindow(
native,
isRenderLoopArmed: () => true,
requestClose: () => closeRequested = true);
GameWindowLifetimeReport report = lifetime.CompleteAndReleaseNativeWindow();
Assert.Equal(GameWindowLifetimeStatus.CompleteWithDeferredNativeRelease, report.Status);
Assert.Equal("native window", report.BlockedStage);
Assert.Null(report.Error);
Assert.Empty(report.CleanupFailures);
Assert.Equal(["owner"], calls);
Assert.Equal(0, native.DisposeCalls);
Assert.True(closeRequested);
Assert.True(lifetime.RetainsShutdownGraph);
Assert.True(report.IsTerminal);
// Idempotent: the armed predicate never flips back false in this
// test double, so a repeated call must keep returning the same
// deferred report without a second Dispose attempt.
GameWindowLifetimeReport repeated = lifetime.CompleteAndReleaseNativeWindow();
Assert.Same(report, repeated);
Assert.Equal(0, native.DisposeCalls);
}
[Fact]
public void UnarmedRenderLoopStillDisposesNativeWindowNormally()
{
// The healthy post-loop path (Run()'s tail, GameWindow.cs) always
// completes with the loop unarmed. This pins that the new optional
// isRenderLoopArmed/requestClose parameters do not change that path:
// Dispose still runs exactly once, in the same order as before.
var calls = new List<string>();
var transaction = new ResourceShutdownTransaction(
new ResourceShutdownStage("owners",
[
new("owner", () => calls.Add("owner")),
]));
var lifetime = new GameWindowLifetime(() => transaction);
var native = new RecordingDisposable("native", calls);
bool closeRequested = false;
lifetime.PublishNativeWindow(
native,
isRenderLoopArmed: () => false,
requestClose: () => closeRequested = true);
GameWindowLifetimeReport report = lifetime.CompleteAndReleaseNativeWindow();
Assert.Equal(GameWindowLifetimeStatus.Complete, report.Status);
Assert.Null(report.BlockedStage);
Assert.Equal(["owner", "native"], calls);
Assert.Equal(1, native.DisposeCalls);
Assert.False(closeRequested);
Assert.False(lifetime.RetainsShutdownGraph);
}
[Fact]
public void HardBarrierFailureRetriesWithoutReplayThenCompletes()
{
int stableCalls = 0;
int pendingCalls = 0;
bool allowPending = false;
var transaction = new ResourceShutdownTransaction(
new ResourceShutdownStage("session barrier",
[
new("stable", () => stableCalls++),
new("pending", () =>
{
pendingCalls++;
if (!allowPending)
throw new InvalidOperationException("pending");
}),
]));
var lifetime = new GameWindowLifetime(() => transaction);
GameWindowLifetimeReport first = lifetime.TryComplete();
Assert.Equal(GameWindowLifetimeStatus.RetryableIncomplete, first.Status);
Assert.Equal("session barrier", first.BlockedStage);
int failedAttempts = pendingCalls;
allowPending = true;
GameWindowLifetimeReport second = lifetime.CompleteAndReleaseNativeWindow();
Assert.Equal(GameWindowLifetimeStatus.Complete, second.Status);
Assert.Equal(1, stableCalls);
Assert.Equal(failedAttempts + 1, pendingCalls);
}
[Fact]
public void PersistentSoftFailureCompletesAndRetainsStructuredReport()
{
int dependentCalls = 0;
var physicalFailure = new InvalidOperationException("remove failed");
var transaction = new ResourceShutdownTransaction(
new ResourceShutdownStage("physical ingress cleanup",
[
new(
"mouse",
() => throw physicalFailure,
ResourceShutdownOperationPolicy.ReportAndContinue),
]),
new ResourceShutdownStage("dependents",
[
new("dependent", () => dependentCalls++),
]));
var lifetime = new GameWindowLifetime(() => transaction);
GameWindowLifetimeReport report = lifetime.CompleteAndReleaseNativeWindow();
Assert.Equal(GameWindowLifetimeStatus.CompleteWithCleanupFailures, report.Status);
Assert.Equal(1, dependentCalls);
ResourceShutdownCleanupFailure cleanup = Assert.Single(report.CleanupFailures);
Assert.Equal("physical ingress cleanup", cleanup.Stage);
Assert.Equal("mouse", cleanup.Operation);
Assert.Same(physicalFailure, cleanup.Error);
Assert.Same(report, lifetime.CompleteAndReleaseNativeWindow());
Assert.False(lifetime.RetainsShutdownGraph);
}
[Fact]
public void PersistentHardFailureUsesNativeFallbackAndBecomesTerminal()
{
var calls = new List<string>();
int attempts = 0;
var transaction = new ResourceShutdownTransaction(
new ResourceShutdownStage("GPU barrier",
[
new("drain", () =>
{
attempts++;
throw new InvalidOperationException("driver wedged");
}),
]),
new ResourceShutdownStage("must stay protected",
[
new("dependent", () => calls.Add("dependent")),
]));
var lifetime = new GameWindowLifetime(() => transaction);
var native = new RecordingDisposable("native", calls);
lifetime.PublishNativeWindow(native);
GameWindowLifetimeReport report = lifetime.CompleteAndReleaseNativeWindow();
int terminalAttempts = attempts;
Assert.Equal(GameWindowLifetimeStatus.AbandonedIncomplete, report.Status);
Assert.Equal("GPU barrier", report.BlockedStage);
Assert.NotNull(report.Error);
Assert.Equal(["native"], calls);
Assert.Same(report, lifetime.TryComplete());
Assert.Same(report, lifetime.CompleteAndReleaseNativeWindow());
Assert.Equal(terminalAttempts, attempts);
Assert.Equal(1, native.DisposeCalls);
Assert.True(lifetime.RetainsShutdownGraph);
}
[Fact]
public void NativeReleaseFailureIsTerminalAndNeverRetried()
{
var transaction = new ResourceShutdownTransaction();
var lifetime = new GameWindowLifetime(() => transaction);
var native = new ThrowingDisposable();
lifetime.PublishNativeWindow(native);
GameWindowLifetimeReport report = lifetime.CompleteAndReleaseNativeWindow();
Assert.Equal(GameWindowLifetimeStatus.AbandonedIncomplete, report.Status);
Assert.Equal("native window", report.BlockedStage);
Assert.Equal(1, native.DisposeCalls);
Assert.Same(report, lifetime.CompleteAndReleaseNativeWindow());
Assert.Equal(1, native.DisposeCalls);
Assert.True(lifetime.RetainsShutdownGraph);
}
[Fact]
public void ReentrantCompletionDoesNotAbandonOrReplayOuterProgress()
{
GameWindowLifetime? lifetime = null;
GameWindowLifetimeReport? nested = null;
int calls = 0;
var transaction = new ResourceShutdownTransaction(
new ResourceShutdownStage("owner",
[
new("reentrant", () =>
{
calls++;
nested = lifetime!.CompleteAndReleaseNativeWindow();
}),
]));
lifetime = new GameWindowLifetime(() => transaction);
GameWindowLifetimeReport outer = lifetime.TryComplete();
Assert.NotNull(nested);
Assert.Equal(GameWindowLifetimeStatus.Active, nested.Status);
Assert.Equal(GameWindowLifetimeStatus.Complete, outer.Status);
Assert.Equal(1, calls);
}
[Fact]
public void ConstructedNeverRunCompletesWithoutNativeWindow()
{
var lifetime = new GameWindowLifetime(
static () => new ResourceShutdownTransaction());
GameWindowLifetimeReport report = lifetime.CompleteAndReleaseNativeWindow();
Assert.Equal(GameWindowLifetimeStatus.Complete, report.Status);
Assert.Same(report, lifetime.CompleteAndReleaseNativeWindow());
Assert.False(lifetime.RetainsShutdownGraph);
}
private sealed class RecordingDisposable(string name, List<string> calls)
: IDisposable
{
public int DisposeCalls { get; private set; }
public void Dispose()
{
DisposeCalls++;
calls.Add(name);
}
}
private sealed class ThrowingDisposable : IDisposable
{
public int DisposeCalls { get; private set; }
public void Dispose()
{
DisposeCalls++;
throw new InvalidOperationException("native failed");
}
}
}