refactor(app): define ordered composition contract
This commit is contained in:
parent
1d04b94da2
commit
adb204560d
6 changed files with 927 additions and 0 deletions
242
src/AcDream.App/Composition/CompositionAcquisitionScope.cs
Normal file
242
src/AcDream.App/Composition/CompositionAcquisitionScope.cs
Normal 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();
|
||||
}
|
||||
119
src/AcDream.App/Composition/GameWindowCompositionPipeline.cs
Normal file
119
src/AcDream.App/Composition/GameWindowCompositionPipeline.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
72
src/AcDream.App/Composition/GameWindowPlatformAcquisition.cs
Normal file
72
src/AcDream.App/Composition/GameWindowPlatformAcquisition.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue