fix(app): #343 — a wounded render loop defers the native release instead of throwing over the real failure
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>
This commit is contained in:
Erik 2026-08-07 10:28:29 +02:00
parent 52bdf4df71
commit 6c6664a685
4 changed files with 194 additions and 10 deletions

View file

@ -32,6 +32,81 @@ public sealed class GameWindowLifetimeTests
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()
{