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");
+ }
+ }
+}