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
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:
parent
52bdf4df71
commit
6c6664a685
4 changed files with 194 additions and 10 deletions
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue