refactor(app): define ordered composition contract

This commit is contained in:
Erik 2026-07-22 15:03:40 +02:00
parent 1d04b94da2
commit adb204560d
6 changed files with 927 additions and 0 deletions

View file

@ -0,0 +1,242 @@
using System.Runtime.ExceptionServices;
using AcDream.App.Rendering;
namespace AcDream.App.Composition;
/// <summary>
/// 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.
/// </summary>
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<Entry> _entries = [];
private bool _cleanupActive;
private bool _closed;
public bool IsCleanupComplete =>
_entries.All(static entry => entry.State is not EntryState.Owned);
public CompositionAcquisitionLease<T> Acquire<T>(
string name,
Func<T> factory,
Action<T> 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<T> Own<T>(
string name,
T resource,
Action<T> 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<T>(
this,
entry,
resource);
}
/// <summary>
/// Closes a successful phase. Every acquisition must already have moved to
/// a typed long-lived owner or aggregate owner.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public void RollbackAndThrow(Exception constructionFailure)
{
ArgumentNullException.ThrowIfNull(constructionFailure);
_closed = true;
try
{
RetryCleanup();
}
catch (AggregateException cleanupFailure)
{
var failures = new List<Exception> { 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<Exception>? 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<T>(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<T>
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<T> publish)
{
ArgumentNullException.ThrowIfNull(publish);
publish(Resource);
return Transfer();
}
}
}
/// <summary>
/// Construction failure whose incomplete rollback remains retryable by the
/// process lifetime cleanup ledger.
/// </summary>
internal sealed class CompositionAcquisitionException : AggregateException,
IRetryableResourceCleanup
{
private readonly IRetryableResourceCleanup _cleanup;
public CompositionAcquisitionException(
string message,
IRetryableResourceCleanup cleanup,
IEnumerable<Exception> failures)
: base(message, failures)
{
_cleanup = cleanup ?? throw new ArgumentNullException(nameof(cleanup));
}
public bool IsCleanupComplete => _cleanup.IsCleanupComplete;
public void RetryCleanup() => _cleanup.RetryCleanup();
}

View file

@ -0,0 +1,119 @@
namespace AcDream.App.Composition;
internal interface IHostInputCameraCompositionPhase<in TPlatform, out TResult>
{
TResult Compose(TPlatform platform);
}
internal interface IContentEffectsAudioCompositionPhase<in TPlatform, in THost, out TResult>
{
TResult Compose(TPlatform platform, THost host);
}
internal interface ISettingsDevToolsCompositionPhase<in TPlatform, in THost, in TContent, out TResult>
{
TResult Compose(TPlatform platform, THost host, TContent content);
}
internal interface IWorldRenderCompositionPhase<in TPlatform, in TContent, in TSettings, out TResult>
{
TResult Compose(TPlatform platform, TContent content, TSettings settings);
}
internal interface IInteractionUiCompositionPhase<in THost, in TContent, in TSettings, in TWorld, out TResult>
{
TResult Compose(THost host, TContent content, TSettings settings, TWorld world);
}
internal interface ILivePresentationCompositionPhase<in THost, in TContent, in TWorld, in TInteraction, out TResult>
{
TResult Compose(THost host, TContent content, TWorld world, TInteraction interaction);
}
internal interface ISessionPlayerCompositionPhase<in THost, in TContent, in TWorld, in TInteraction, in TLive, out TResult>
{
TResult Compose(
THost host,
TContent content,
TWorld world,
TInteraction interaction,
TLive live);
}
internal interface IFrameRootCompositionPhase<in THost, in TSettings, in TWorld, in TInteraction, in TLive, in TSession, out TResult>
{
TResult Compose(
THost host,
TSettings settings,
TWorld world,
TInteraction interaction,
TLive live,
TSession session);
}
internal interface ISessionStartCompositionPhase<in TFrame>
{
void Start(TFrame frame);
}
/// <summary>
/// Fixed startup topology. It intentionally retains no cumulative context or
/// final runtime graph; immutable phase results exist only as locals during
/// one composition run.
/// </summary>
internal sealed class GameWindowCompositionPipeline<
TPlatform,
THost,
TContent,
TSettings,
TWorld,
TInteraction,
TLive,
TSession,
TFrame>
{
private readonly IHostInputCameraCompositionPhase<TPlatform, THost> _host;
private readonly IContentEffectsAudioCompositionPhase<TPlatform, THost, TContent> _content;
private readonly ISettingsDevToolsCompositionPhase<TPlatform, THost, TContent, TSettings> _settings;
private readonly IWorldRenderCompositionPhase<TPlatform, TContent, TSettings, TWorld> _world;
private readonly IInteractionUiCompositionPhase<THost, TContent, TSettings, TWorld, TInteraction> _interaction;
private readonly ILivePresentationCompositionPhase<THost, TContent, TWorld, TInteraction, TLive> _live;
private readonly ISessionPlayerCompositionPhase<THost, TContent, TWorld, TInteraction, TLive, TSession> _session;
private readonly IFrameRootCompositionPhase<THost, TSettings, TWorld, TInteraction, TLive, TSession, TFrame> _frame;
private readonly ISessionStartCompositionPhase<TFrame> _start;
public GameWindowCompositionPipeline(
IHostInputCameraCompositionPhase<TPlatform, THost> host,
IContentEffectsAudioCompositionPhase<TPlatform, THost, TContent> content,
ISettingsDevToolsCompositionPhase<TPlatform, THost, TContent, TSettings> settings,
IWorldRenderCompositionPhase<TPlatform, TContent, TSettings, TWorld> world,
IInteractionUiCompositionPhase<THost, TContent, TSettings, TWorld, TInteraction> interaction,
ILivePresentationCompositionPhase<THost, TContent, TWorld, TInteraction, TLive> live,
ISessionPlayerCompositionPhase<THost, TContent, TWorld, TInteraction, TLive, TSession> session,
IFrameRootCompositionPhase<THost, TSettings, TWorld, TInteraction, TLive, TSession, TFrame> frame,
ISessionStartCompositionPhase<TFrame> 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);
}
}

View file

@ -0,0 +1,72 @@
namespace AcDream.App.Composition;
internal interface IGameWindowPlatformPublication<in TGraphics, in TInput>
where TGraphics : class
where TInput : class
{
void PublishGraphics(TGraphics graphics);
void PublishInput(TInput input);
}
internal sealed record GameWindowPlatformResult<TGraphics, TInput>(
TGraphics Graphics,
TInput Input)
where TGraphics : class
where TInput : class;
internal enum GameWindowPlatformAcquisitionPoint
{
GraphicsPublished,
InputPublished,
}
/// <summary>
/// Exact host-platform acquisition prelude. Graphics ownership publishes
/// before input construction starts so a later input failure remains
/// recoverable through the normal lifetime transaction.
/// </summary>
internal static class GameWindowPlatformAcquisition
{
public static GameWindowPlatformResult<TGraphics, TInput> Acquire<TGraphics, TInput>(
Func<TGraphics> graphicsFactory,
Action<TGraphics> releaseUnpublishedGraphics,
Func<TInput> inputFactory,
Action<TInput> releaseUnpublishedInput,
IGameWindowPlatformPublication<TGraphics, TInput> publication,
Action<GameWindowPlatformAcquisitionPoint>? 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<TGraphics, TInput>(graphics, input);
}
catch (Exception failure)
{
scope.RollbackAndThrow(failure);
throw new System.Diagnostics.UnreachableException();
}
}
}