From adb204560deadcf30b3d1b25172c0a5fcd6fb5b7 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 15:03:40 +0200 Subject: [PATCH] refactor(app): define ordered composition contract --- .../CompositionAcquisitionScope.cs | 242 ++++++++++++++++++ .../GameWindowCompositionPipeline.cs | 119 +++++++++ .../GameWindowPlatformAcquisition.cs | 72 ++++++ .../CompositionAcquisitionScopeTests.cs | 114 +++++++++ .../GameWindowCompositionPipelineTests.cs | 189 ++++++++++++++ .../GameWindowPlatformAcquisitionTests.cs | 191 ++++++++++++++ 6 files changed, 927 insertions(+) create mode 100644 src/AcDream.App/Composition/CompositionAcquisitionScope.cs create mode 100644 src/AcDream.App/Composition/GameWindowCompositionPipeline.cs create mode 100644 src/AcDream.App/Composition/GameWindowPlatformAcquisition.cs create mode 100644 tests/AcDream.App.Tests/Composition/CompositionAcquisitionScopeTests.cs create mode 100644 tests/AcDream.App.Tests/Composition/GameWindowCompositionPipelineTests.cs create mode 100644 tests/AcDream.App.Tests/Composition/GameWindowPlatformAcquisitionTests.cs diff --git a/src/AcDream.App/Composition/CompositionAcquisitionScope.cs b/src/AcDream.App/Composition/CompositionAcquisitionScope.cs new file mode 100644 index 00000000..09e4b85b --- /dev/null +++ b/src/AcDream.App/Composition/CompositionAcquisitionScope.cs @@ -0,0 +1,242 @@ +using System.Runtime.ExceptionServices; +using AcDream.App.Rendering; + +namespace AcDream.App.Composition; + +/// +/// Owns resources acquired inside one startup-composition phase until each +/// resource is published to its long-lived owner. Unpublished resources roll +/// back in reverse dependency order; successful cleanup never replays. +/// +internal sealed class CompositionAcquisitionScope : IRetryableResourceCleanup +{ + internal enum EntryState + { + Owned, + Transferred, + Released, + } + + internal sealed class Entry(string name, object resource, Action release) + { + public string Name { get; } = name; + public object Resource { get; } = resource; + public Action Release { get; } = release; + public EntryState State { get; set; } = EntryState.Owned; + } + + private readonly List _entries = []; + private bool _cleanupActive; + private bool _closed; + + public bool IsCleanupComplete => + _entries.All(static entry => entry.State is not EntryState.Owned); + + public CompositionAcquisitionLease Acquire( + string name, + Func factory, + Action release) + where T : class + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(factory); + ArgumentNullException.ThrowIfNull(release); + EnsureAcceptingOwnership(); + + T resource = factory() + ?? throw new InvalidOperationException( + $"Composition factory '{name}' returned null."); + return Own(name, resource, release); + } + + public CompositionAcquisitionLease Own( + string name, + T resource, + Action release) + where T : class + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(release); + EnsureAcceptingOwnership(); + + var entry = new Entry(name, resource, () => release(resource)); + _entries.Add(entry); + return new CompositionAcquisitionLease( + this, + entry, + resource); + } + + /// + /// Closes a successful phase. Every acquisition must already have moved to + /// a typed long-lived owner or aggregate owner. + /// + public void Complete() + { + if (_cleanupActive) + throw new InvalidOperationException( + "Composition cleanup is currently active."); + if (_closed) + return; + if (!IsCleanupComplete) + { + string pending = string.Join( + ", ", + _entries + .Where(static entry => entry.State == EntryState.Owned) + .Select(static entry => entry.Name)); + throw new InvalidOperationException( + $"Composition phase completed with unpublished resources: {pending}."); + } + + _closed = true; + } + + /// + /// Rolls back the unpublished prefix and then rethrows the construction + /// failure. If cleanup itself fails, the returned exception retains this + /// exact scope as retry ownership. + /// + public void RollbackAndThrow(Exception constructionFailure) + { + ArgumentNullException.ThrowIfNull(constructionFailure); + _closed = true; + + try + { + RetryCleanup(); + } + catch (AggregateException cleanupFailure) + { + var failures = new List { constructionFailure }; + failures.AddRange(cleanupFailure.InnerExceptions); + throw new CompositionAcquisitionException( + "Startup composition failed and rollback remains incomplete.", + this, + failures); + } + + ExceptionDispatchInfo.Capture(constructionFailure).Throw(); + } + + public void RetryCleanup() + { + if (_cleanupActive || IsCleanupComplete) + return; + + _closed = true; + _cleanupActive = true; + List? failures = null; + try + { + for (int i = _entries.Count - 1; i >= 0; i--) + { + Entry entry = _entries[i]; + if (entry.State != EntryState.Owned) + continue; + + try + { + entry.Release(); + entry.State = EntryState.Released; + } + catch (Exception failure) + { + (failures ??= []).Add(new InvalidOperationException( + $"Composition cleanup operation '{entry.Name}' failed.", + failure)); + } + } + } + finally + { + _cleanupActive = false; + } + + if (failures is not null) + { + throw new AggregateException( + "Startup composition rollback remains incomplete.", + failures); + } + } + + private void EnsureAcceptingOwnership() + { + if (_closed || _cleanupActive) + throw new InvalidOperationException( + "The composition acquisition scope is no longer accepting ownership."); + } + + private void Transfer(Entry entry, T expected) + where T : class + { + if (_closed || _cleanupActive) + throw new InvalidOperationException( + "The composition acquisition scope is no longer transferable."); + if (!ReferenceEquals(entry.Resource, expected)) + throw new InvalidOperationException( + "The acquisition lease does not own the expected resource."); + if (entry.State != EntryState.Owned) + throw new InvalidOperationException( + $"Composition resource '{entry.Name}' has already been transferred."); + + entry.State = EntryState.Transferred; + } + + internal sealed class CompositionAcquisitionLease + where T : class + { + private readonly CompositionAcquisitionScope _scope; + private readonly Entry _entry; + + internal CompositionAcquisitionLease( + CompositionAcquisitionScope scope, + Entry entry, + T resource) + { + _scope = scope; + _entry = entry; + Resource = resource; + } + + public T Resource { get; } + + public T Transfer() + { + _scope.Transfer(_entry, Resource); + return Resource; + } + + public T Publish(Action publish) + { + ArgumentNullException.ThrowIfNull(publish); + publish(Resource); + return Transfer(); + } + } +} + +/// +/// Construction failure whose incomplete rollback remains retryable by the +/// process lifetime cleanup ledger. +/// +internal sealed class CompositionAcquisitionException : AggregateException, + IRetryableResourceCleanup +{ + private readonly IRetryableResourceCleanup _cleanup; + + public CompositionAcquisitionException( + string message, + IRetryableResourceCleanup cleanup, + IEnumerable failures) + : base(message, failures) + { + _cleanup = cleanup ?? throw new ArgumentNullException(nameof(cleanup)); + } + + public bool IsCleanupComplete => _cleanup.IsCleanupComplete; + + public void RetryCleanup() => _cleanup.RetryCleanup(); +} diff --git a/src/AcDream.App/Composition/GameWindowCompositionPipeline.cs b/src/AcDream.App/Composition/GameWindowCompositionPipeline.cs new file mode 100644 index 00000000..8a6b835c --- /dev/null +++ b/src/AcDream.App/Composition/GameWindowCompositionPipeline.cs @@ -0,0 +1,119 @@ +namespace AcDream.App.Composition; + +internal interface IHostInputCameraCompositionPhase +{ + TResult Compose(TPlatform platform); +} + +internal interface IContentEffectsAudioCompositionPhase +{ + TResult Compose(TPlatform platform, THost host); +} + +internal interface ISettingsDevToolsCompositionPhase +{ + TResult Compose(TPlatform platform, THost host, TContent content); +} + +internal interface IWorldRenderCompositionPhase +{ + TResult Compose(TPlatform platform, TContent content, TSettings settings); +} + +internal interface IInteractionUiCompositionPhase +{ + TResult Compose(THost host, TContent content, TSettings settings, TWorld world); +} + +internal interface ILivePresentationCompositionPhase +{ + TResult Compose(THost host, TContent content, TWorld world, TInteraction interaction); +} + +internal interface ISessionPlayerCompositionPhase +{ + TResult Compose( + THost host, + TContent content, + TWorld world, + TInteraction interaction, + TLive live); +} + +internal interface IFrameRootCompositionPhase +{ + TResult Compose( + THost host, + TSettings settings, + TWorld world, + TInteraction interaction, + TLive live, + TSession session); +} + +internal interface ISessionStartCompositionPhase +{ + void Start(TFrame frame); +} + +/// +/// Fixed startup topology. It intentionally retains no cumulative context or +/// final runtime graph; immutable phase results exist only as locals during +/// one composition run. +/// +internal sealed class GameWindowCompositionPipeline< + TPlatform, + THost, + TContent, + TSettings, + TWorld, + TInteraction, + TLive, + TSession, + TFrame> +{ + private readonly IHostInputCameraCompositionPhase _host; + private readonly IContentEffectsAudioCompositionPhase _content; + private readonly ISettingsDevToolsCompositionPhase _settings; + private readonly IWorldRenderCompositionPhase _world; + private readonly IInteractionUiCompositionPhase _interaction; + private readonly ILivePresentationCompositionPhase _live; + private readonly ISessionPlayerCompositionPhase _session; + private readonly IFrameRootCompositionPhase _frame; + private readonly ISessionStartCompositionPhase _start; + + public GameWindowCompositionPipeline( + IHostInputCameraCompositionPhase host, + IContentEffectsAudioCompositionPhase content, + ISettingsDevToolsCompositionPhase settings, + IWorldRenderCompositionPhase world, + IInteractionUiCompositionPhase interaction, + ILivePresentationCompositionPhase live, + ISessionPlayerCompositionPhase session, + IFrameRootCompositionPhase frame, + ISessionStartCompositionPhase start) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _content = content ?? throw new ArgumentNullException(nameof(content)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _world = world ?? throw new ArgumentNullException(nameof(world)); + _interaction = interaction ?? throw new ArgumentNullException(nameof(interaction)); + _live = live ?? throw new ArgumentNullException(nameof(live)); + _session = session ?? throw new ArgumentNullException(nameof(session)); + _frame = frame ?? throw new ArgumentNullException(nameof(frame)); + _start = start ?? throw new ArgumentNullException(nameof(start)); + } + + public void Run(TPlatform platform) + { + THost host = _host.Compose(platform); + TContent content = _content.Compose(platform, host); + TSettings settings = _settings.Compose(platform, host, content); + TWorld world = _world.Compose(platform, content, settings); + TInteraction interaction = _interaction.Compose(host, content, settings, world); + TLive live = _live.Compose(host, content, world, interaction); + TSession session = _session.Compose(host, content, world, interaction, live); + TFrame frame = _frame.Compose(host, settings, world, interaction, live, session); + _start.Start(frame); + } +} diff --git a/src/AcDream.App/Composition/GameWindowPlatformAcquisition.cs b/src/AcDream.App/Composition/GameWindowPlatformAcquisition.cs new file mode 100644 index 00000000..74c18d3b --- /dev/null +++ b/src/AcDream.App/Composition/GameWindowPlatformAcquisition.cs @@ -0,0 +1,72 @@ +namespace AcDream.App.Composition; + +internal interface IGameWindowPlatformPublication + where TGraphics : class + where TInput : class +{ + void PublishGraphics(TGraphics graphics); + void PublishInput(TInput input); +} + +internal sealed record GameWindowPlatformResult( + TGraphics Graphics, + TInput Input) + where TGraphics : class + where TInput : class; + +internal enum GameWindowPlatformAcquisitionPoint +{ + GraphicsPublished, + InputPublished, +} + +/// +/// Exact host-platform acquisition prelude. Graphics ownership publishes +/// before input construction starts so a later input failure remains +/// recoverable through the normal lifetime transaction. +/// +internal static class GameWindowPlatformAcquisition +{ + public static GameWindowPlatformResult Acquire( + Func graphicsFactory, + Action releaseUnpublishedGraphics, + Func inputFactory, + Action releaseUnpublishedInput, + IGameWindowPlatformPublication publication, + Action? faultInjection = null) + where TGraphics : class + where TInput : class + { + ArgumentNullException.ThrowIfNull(graphicsFactory); + ArgumentNullException.ThrowIfNull(releaseUnpublishedGraphics); + ArgumentNullException.ThrowIfNull(inputFactory); + ArgumentNullException.ThrowIfNull(releaseUnpublishedInput); + ArgumentNullException.ThrowIfNull(publication); + + var scope = new CompositionAcquisitionScope(); + try + { + var graphicsLease = scope.Acquire( + "graphics API", + graphicsFactory, + releaseUnpublishedGraphics); + TGraphics graphics = graphicsLease.Publish(publication.PublishGraphics); + faultInjection?.Invoke(GameWindowPlatformAcquisitionPoint.GraphicsPublished); + + var inputLease = scope.Acquire( + "input context", + inputFactory, + releaseUnpublishedInput); + TInput input = inputLease.Publish(publication.PublishInput); + faultInjection?.Invoke(GameWindowPlatformAcquisitionPoint.InputPublished); + + scope.Complete(); + return new GameWindowPlatformResult(graphics, input); + } + catch (Exception failure) + { + scope.RollbackAndThrow(failure); + throw new System.Diagnostics.UnreachableException(); + } + } +} diff --git a/tests/AcDream.App.Tests/Composition/CompositionAcquisitionScopeTests.cs b/tests/AcDream.App.Tests/Composition/CompositionAcquisitionScopeTests.cs new file mode 100644 index 00000000..716bf385 --- /dev/null +++ b/tests/AcDream.App.Tests/Composition/CompositionAcquisitionScopeTests.cs @@ -0,0 +1,114 @@ +using AcDream.App.Composition; + +namespace AcDream.App.Tests.Composition; + +public sealed class CompositionAcquisitionScopeTests +{ + [Fact] + public void RollbackReleasesOnlyUnpublishedResourcesInReverseOrder() + { + var calls = new List(); + var scope = new CompositionAcquisitionScope(); + var first = scope.Own("first", new Resource("first"), value => calls.Add(value.Name)); + var published = scope.Own("published", new Resource("published"), value => calls.Add(value.Name)); + scope.Own("last", new Resource("last"), value => calls.Add(value.Name)); + published.Transfer(); + + InvalidOperationException original = Assert.Throws(() => + scope.RollbackAndThrow(new InvalidOperationException("construction failed"))); + + Assert.Equal("construction failed", original.Message); + Assert.Equal(["last", "first"], calls); + Assert.True(scope.IsCleanupComplete); + Assert.Equal("first", first.Resource.Name); + } + + [Fact] + public void CleanupFailureRetainsOriginalAndRetriesOnlyUnfinishedAction() + { + var calls = new List(); + int middleFailures = 1; + var scope = new CompositionAcquisitionScope(); + scope.Own("first", new Resource("first"), value => calls.Add(value.Name)); + scope.Own("middle", new Resource("middle"), value => + { + calls.Add(value.Name); + if (middleFailures-- > 0) + throw new InvalidOperationException("release failed"); + }); + scope.Own("last", new Resource("last"), value => calls.Add(value.Name)); + + CompositionAcquisitionException failure = + Assert.Throws(() => + scope.RollbackAndThrow(new InvalidOperationException("construction failed"))); + + Assert.False(failure.IsCleanupComplete); + Assert.Contains( + failure.InnerExceptions, + error => error.Message == "construction failed"); + Assert.Equal(["last", "middle", "first"], calls); + + failure.RetryCleanup(); + failure.RetryCleanup(); + + Assert.True(failure.IsCleanupComplete); + Assert.Equal(["last", "middle", "first", "middle"], calls); + } + + [Fact] + public void PublicationTransfersOnlyAfterPublisherReturns() + { + int releases = 0; + var failing = new CompositionAcquisitionScope(); + var lease = failing.Own( + "resource", + new Resource("resource"), + _ => releases++); + + Assert.Throws(() => + lease.Publish(_ => throw new InvalidOperationException("publish failed"))); + Assert.Throws(() => + failing.RollbackAndThrow(new InvalidOperationException("phase failed"))); + Assert.Equal(1, releases); + + var successful = new CompositionAcquisitionScope(); + var transferred = successful.Own( + "resource", + new Resource("resource"), + _ => releases++); + transferred.Publish(_ => { }); + successful.Complete(); + + Assert.Equal(1, releases); + Assert.Throws(transferred.Transfer); + } + + [Fact] + public void CompleteRejectsAnUnpublishedAcquisition() + { + var scope = new CompositionAcquisitionScope(); + scope.Own("pending", new Resource("pending"), _ => { }); + + InvalidOperationException failure = Assert.Throws(scope.Complete); + + Assert.Contains("pending", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void ThrowingFactoryNeverCreatesCleanupOwnership() + { + var scope = new CompositionAcquisitionScope(); + int releases = 0; + + Assert.Throws(() => + scope.Acquire( + "resource", + () => throw new InvalidOperationException("factory failed"), + _ => releases++)); + + Assert.True(scope.IsCleanupComplete); + Assert.Equal(0, releases); + } + + private sealed record Resource(string Name); +} diff --git a/tests/AcDream.App.Tests/Composition/GameWindowCompositionPipelineTests.cs b/tests/AcDream.App.Tests/Composition/GameWindowCompositionPipelineTests.cs new file mode 100644 index 00000000..56bf2695 --- /dev/null +++ b/tests/AcDream.App.Tests/Composition/GameWindowCompositionPipelineTests.cs @@ -0,0 +1,189 @@ +using AcDream.App.Composition; + +namespace AcDream.App.Tests.Composition; + +public sealed class GameWindowCompositionPipelineTests +{ + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + [InlineData(7)] + [InlineData(8)] + [InlineData(9)] + public void FailureRunsExactlyTheRequiredPrefix(int failingPhase) + { + var phases = new RecordingPhases(failingPhase); + GameWindowCompositionPipeline + pipeline = phases.BuildPipeline(); + + Assert.Throws(() => pipeline.Run(phases.Platform)); + + Assert.Equal( + Enumerable.Range(1, failingPhase).Select(value => $"phase-{value}"), + phases.Calls); + } + + [Fact] + public void SuccessPassesExactResultsAndStartsSessionLast() + { + var phases = new RecordingPhases(failingPhase: 0); + GameWindowCompositionPipeline + pipeline = phases.BuildPipeline(); + + pipeline.Run(phases.Platform); + + Assert.Equal( + Enumerable.Range(1, 9).Select(value => $"phase-{value}"), + phases.Calls); + Assert.Equal("phase-9", phases.Calls[^1]); + } + + private sealed record Token(int Phase); + + private sealed class RecordingPhases(int failingPhase) : + IHostInputCameraCompositionPhase, + IContentEffectsAudioCompositionPhase, + ISettingsDevToolsCompositionPhase, + IWorldRenderCompositionPhase, + IInteractionUiCompositionPhase, + ILivePresentationCompositionPhase, + ISessionPlayerCompositionPhase, + IFrameRootCompositionPhase, + ISessionStartCompositionPhase + { + public Token Platform { get; } = new(0); + public List Calls { get; } = []; + + private readonly Token _host = new(1); + private readonly Token _content = new(2); + private readonly Token _settings = new(3); + private readonly Token _world = new(4); + private readonly Token _interaction = new(5); + private readonly Token _live = new(6); + private readonly Token _session = new(7); + private readonly Token _frame = new(8); + + public GameWindowCompositionPipeline + BuildPipeline() => new(this, this, this, this, this, this, this, this, this); + + Token IHostInputCameraCompositionPhase.Compose(Token platform) + { + Assert.Same(Platform, platform); + Record(1); + return _host; + } + + Token IContentEffectsAudioCompositionPhase.Compose( + Token platform, + Token host) + { + Assert.Same(Platform, platform); + Assert.Same(_host, host); + Record(2); + return _content; + } + + Token ISettingsDevToolsCompositionPhase.Compose( + Token platform, + Token host, + Token content) + { + Assert.Same(Platform, platform); + Assert.Same(_host, host); + Assert.Same(_content, content); + Record(3); + return _settings; + } + + Token IWorldRenderCompositionPhase.Compose( + Token platform, + Token content, + Token settings) + { + Assert.Same(Platform, platform); + Assert.Same(_content, content); + Assert.Same(_settings, settings); + Record(4); + return _world; + } + + Token IInteractionUiCompositionPhase.Compose( + Token host, + Token content, + Token settings, + Token world) + { + Assert.Same(_host, host); + Assert.Same(_content, content); + Assert.Same(_settings, settings); + Assert.Same(_world, world); + Record(5); + return _interaction; + } + + Token ILivePresentationCompositionPhase.Compose( + Token host, + Token content, + Token world, + Token interaction) + { + Assert.Same(_host, host); + Assert.Same(_content, content); + Assert.Same(_world, world); + Assert.Same(_interaction, interaction); + Record(6); + return _live; + } + + Token ISessionPlayerCompositionPhase.Compose( + Token host, + Token content, + Token world, + Token interaction, + Token live) + { + Assert.Same(_host, host); + Assert.Same(_content, content); + Assert.Same(_world, world); + Assert.Same(_interaction, interaction); + Assert.Same(_live, live); + Record(7); + return _session; + } + + Token IFrameRootCompositionPhase.Compose( + Token host, + Token settings, + Token world, + Token interaction, + Token live, + Token session) + { + Assert.Same(_host, host); + Assert.Same(_settings, settings); + Assert.Same(_world, world); + Assert.Same(_interaction, interaction); + Assert.Same(_live, live); + Assert.Same(_session, session); + Record(8); + return _frame; + } + + void ISessionStartCompositionPhase.Start(Token frame) + { + Assert.Same(_frame, frame); + Record(9); + } + + private void Record(int phase) + { + Calls.Add($"phase-{phase}"); + if (failingPhase == phase) + throw new InvalidOperationException($"phase {phase} failed"); + } + } +} diff --git a/tests/AcDream.App.Tests/Composition/GameWindowPlatformAcquisitionTests.cs b/tests/AcDream.App.Tests/Composition/GameWindowPlatformAcquisitionTests.cs new file mode 100644 index 00000000..0223bb00 --- /dev/null +++ b/tests/AcDream.App.Tests/Composition/GameWindowPlatformAcquisitionTests.cs @@ -0,0 +1,191 @@ +using AcDream.App.Composition; + +namespace AcDream.App.Tests.Composition; + +public sealed class GameWindowPlatformAcquisitionTests +{ + [Fact] + public void AcquirePublishesGraphicsBeforeInputConstruction() + { + var trace = new List(); + var owner = new Publication(trace); + var graphics = new Resource("graphics", trace); + var input = new Resource("input", trace); + + GameWindowPlatformResult result = + GameWindowPlatformAcquisition.Acquire( + () => + { + trace.Add("gl.factory"); + return graphics; + }, + value => value.ReleaseUnpublished(), + () => + { + Assert.Same(graphics, owner.Graphics); + trace.Add("input.factory"); + return input; + }, + value => value.ReleaseUnpublished(), + owner); + + Assert.Same(graphics, result.Graphics); + Assert.Same(input, result.Input); + Assert.Equal( + ["gl.factory", "gl.publish", "input.factory", "input.publish"], + trace); + + owner.Dispose(); + owner.Dispose(); + Assert.Equal(1, graphics.OwnerReleaseCalls); + Assert.Equal(1, input.OwnerReleaseCalls); + Assert.Equal(0, graphics.UnpublishedReleaseCalls); + Assert.Equal(0, input.UnpublishedReleaseCalls); + Assert.Equal("input.owner-release", trace[^2]); + Assert.Equal("graphics.owner-release", trace[^1]); + } + + [Fact] + public void GraphicsFactoryFailureNeverConstructsInput() + { + var trace = new List(); + var owner = new Publication(trace); + + Assert.Throws(() => + GameWindowPlatformAcquisition.Acquire( + () => throw new InvalidOperationException("graphics failed"), + value => value.ReleaseUnpublished(), + () => + { + trace.Add("input.factory"); + return new Resource("input", trace); + }, + value => value.ReleaseUnpublished(), + owner)); + + Assert.Empty(trace); + Assert.Null(owner.Graphics); + Assert.Null(owner.Input); + } + + [Fact] + public void InputFactoryFailureLeavesPublishedGraphicsLifetimeOwned() + { + var trace = new List(); + var owner = new Publication(trace); + var graphics = new Resource("graphics", trace); + + Assert.Throws(() => + GameWindowPlatformAcquisition.Acquire( + () => + { + trace.Add("gl.factory"); + return graphics; + }, + value => value.ReleaseUnpublished(), + () => + { + Assert.Same(graphics, owner.Graphics); + trace.Add("input.factory"); + throw new InvalidOperationException("input failed"); + }, + value => value.ReleaseUnpublished(), + owner)); + + Assert.Equal(["gl.factory", "gl.publish", "input.factory"], trace); + Assert.Equal(0, graphics.UnpublishedReleaseCalls); + owner.Dispose(); + Assert.Equal(1, graphics.OwnerReleaseCalls); + } + + [Theory] + [InlineData((int)GameWindowPlatformAcquisitionPoint.GraphicsPublished)] + [InlineData((int)GameWindowPlatformAcquisitionPoint.InputPublished)] + public void FailureImmediatelyAfterPublicationNeverRollsBackPublishedOwner( + int failurePointValue) + { + var failurePoint = (GameWindowPlatformAcquisitionPoint)failurePointValue; + var trace = new List(); + var owner = new Publication(trace); + var graphics = new Resource("graphics", trace); + var input = new Resource("input", trace); + + Assert.Throws(() => + GameWindowPlatformAcquisition.Acquire( + () => graphics, + value => value.ReleaseUnpublished(), + () => input, + value => value.ReleaseUnpublished(), + owner, + point => + { + if (point == failurePoint) + throw new InvalidOperationException("injected failure"); + })); + + Assert.Equal(0, graphics.UnpublishedReleaseCalls); + Assert.Equal(0, input.UnpublishedReleaseCalls); + Assert.NotNull(owner.Graphics); + Assert.Equal( + failurePoint == GameWindowPlatformAcquisitionPoint.InputPublished, + owner.Input is not null); + + owner.Dispose(); + Assert.Equal(1, graphics.OwnerReleaseCalls); + Assert.Equal( + failurePoint == GameWindowPlatformAcquisitionPoint.InputPublished ? 1 : 0, + input.OwnerReleaseCalls); + } + + private sealed class Publication(List trace) : + IGameWindowPlatformPublication, + IDisposable + { + public Resource? Graphics { get; private set; } + public Resource? Input { get; private set; } + + public void PublishGraphics(Resource graphics) + { + if (Graphics is not null) + throw new InvalidOperationException("graphics already published"); + Graphics = graphics; + trace.Add("gl.publish"); + } + + public void PublishInput(Resource input) + { + if (Input is not null) + throw new InvalidOperationException("input already published"); + Input = input; + trace.Add("input.publish"); + } + + public void Dispose() + { + Resource? input = Input; + Input = null; + input?.ReleaseFromOwner(); + Resource? graphics = Graphics; + Graphics = null; + graphics?.ReleaseFromOwner(); + } + } + + private sealed class Resource(string name, List trace) + { + public int UnpublishedReleaseCalls { get; private set; } + public int OwnerReleaseCalls { get; private set; } + + public void ReleaseUnpublished() + { + UnpublishedReleaseCalls++; + trace.Add($"{name}.unpublished-release"); + } + + public void ReleaseFromOwner() + { + OwnerReleaseCalls++; + trace.Add($"{name}.owner-release"); + } + } +}