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
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>
287 lines
11 KiB
C#
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");
|
|
}
|
|
}
|
|
}
|