refactor(lifetime): own render resource prefixes

Give terrain, sky, retained UI, portal preparation, and the update/render frame pair explicit single owners. Make shader, texture, text, bindless, and GL construction prefixes checked and retryable so partial failure cannot lose or replay resource ownership.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 14:42:14 +02:00
parent fec0d94148
commit c87b15303d
41 changed files with 3768 additions and 355 deletions

View file

@ -202,6 +202,8 @@ public sealed class RetailUiRuntime : IDisposable
private ResourceShutdownTransaction? _shutdown;
private bool _disposed;
internal bool IsDisposalComplete => _disposed;
private RetailUiRuntime(RetailUiRuntimeBindings bindings)
{
_bindings = bindings;
@ -211,6 +213,15 @@ public sealed class RetailUiRuntime : IDisposable
bindings.Host.HideWindow);
}
internal static RetailUiRuntime CreateUninitialized(
RetailUiRuntimeBindings bindings)
{
ArgumentNullException.ThrowIfNull(bindings);
return new RetailUiRuntime(bindings);
}
internal void InitializeForLease() => Initialize();
private void Initialize()
{
RetailUiRuntimeBindings bindings = _bindings;
@ -298,7 +309,7 @@ public sealed class RetailUiRuntime : IDisposable
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
{
ArgumentNullException.ThrowIfNull(bindings);
var runtime = new RetailUiRuntime(bindings);
var runtime = CreateUninitialized(bindings);
try
{
runtime.Initialize();

View file

@ -0,0 +1,263 @@
namespace AcDream.App.UI;
/// <summary>
/// Retains one UiHost/runtime ownership chain across partial construction,
/// initialization cleanup, lifetime retry, and explicit terminal abandonment.
/// </summary>
internal sealed class RetailUiRuntimeLease : IDisposable
{
private object? _host;
private Action? _quiesceInput;
private Action? _deactivateInput;
private Action? _disposeHost;
private Func<bool>? _hostDisposalComplete;
private object? _runtime;
private Action? _disposeRuntime;
private Func<bool>? _runtimeDisposalComplete;
private bool _disposalFailed;
private bool _inputQuiesced;
private bool _inputDeactivated;
private bool _operationActive;
private bool _disposed;
private bool _abandoned;
public bool IsDisposalComplete => _disposed;
public bool IsAbandoned => _abandoned;
internal bool RetainsResources => _host is not null || _runtime is not null;
public UiHost AcquireHost(Func<UiHost> factory) => AcquireHostCore(
factory,
static host => host.QuiesceInput(),
static host => host.DeactivateInput(),
static host => host.Dispose(),
static host => host.IsDisposalComplete);
internal T AcquireHostCore<T>(
Func<T> factory,
Action<T> quiesceInput,
Action<T> deactivateInput,
Action<T> dispose,
Func<T, bool> disposalComplete)
where T : class
{
ArgumentNullException.ThrowIfNull(factory);
ArgumentNullException.ThrowIfNull(quiesceInput);
ArgumentNullException.ThrowIfNull(deactivateInput);
ArgumentNullException.ThrowIfNull(dispose);
ArgumentNullException.ThrowIfNull(disposalComplete);
ThrowIfUnavailable();
if (_operationActive || _host is not null)
throw new InvalidOperationException("A retained UI host is already owned.");
_operationActive = true;
try
{
T host = factory()
?? throw new InvalidOperationException("The retained UI host factory returned null.");
_host = host;
_quiesceInput = () => quiesceInput(host);
_deactivateInput = () => deactivateInput(host);
_disposeHost = () => dispose(host);
_hostDisposalComplete = () => disposalComplete(host);
return host;
}
finally
{
_operationActive = false;
}
}
public RetailUiRuntime Mount(Func<RetailUiRuntime> factory) => MountCore(
factory,
static runtime => runtime.InitializeForLease(),
static runtime => runtime.Dispose(),
static runtime => runtime.IsDisposalComplete);
internal T MountCore<T>(
Func<T> factory,
Action<T> initialize,
Action<T> dispose,
Func<T, bool> disposalComplete)
where T : class
{
ArgumentNullException.ThrowIfNull(factory);
ArgumentNullException.ThrowIfNull(initialize);
ArgumentNullException.ThrowIfNull(dispose);
ArgumentNullException.ThrowIfNull(disposalComplete);
ThrowIfUnavailable();
if (_operationActive)
throw new InvalidOperationException("The retained UI lease is changing state.");
if (_host is null)
throw new InvalidOperationException("The retained UI host must be acquired first.");
if (_runtime is not null)
throw new InvalidOperationException("A retained UI runtime is already owned.");
// Construction may throw while the lease still owns the host directly.
_operationActive = true;
T runtime;
try
{
runtime = factory()
?? throw new InvalidOperationException("The retained UI runtime factory returned null.");
}
finally
{
_operationActive = false;
}
// Publish the partial runtime before any Initialize side effect.
_runtime = runtime;
_disposeRuntime = () => dispose(runtime);
_runtimeDisposalComplete = () => disposalComplete(runtime);
_operationActive = true;
try
{
initialize(runtime);
_operationActive = false;
return runtime;
}
catch (Exception initializationFailure)
{
_operationActive = false;
try
{
Dispose();
}
catch (Exception cleanupFailure)
{
throw new AggregateException(
"Retail UI initialization failed and its published partial runtime did not cleanly retire.",
initializationFailure,
cleanupFailure);
}
throw;
}
}
public void QuiesceInput()
{
if (_disposed || _abandoned || _inputQuiesced)
return;
if (_operationActive)
throw new InvalidOperationException("The retained UI lease is changing state.");
_operationActive = true;
_inputQuiesced = true;
try
{
_quiesceInput?.Invoke();
}
catch
{
_inputQuiesced = false;
throw;
}
finally
{
_operationActive = false;
}
}
public void DeactivateInput()
{
if (_disposed || _abandoned || _inputDeactivated)
return;
if (_operationActive)
throw new InvalidOperationException("The retained UI lease is changing state.");
_operationActive = true;
_inputDeactivated = true;
try
{
_deactivateInput?.Invoke();
}
catch
{
_inputDeactivated = false;
throw;
}
finally
{
_operationActive = false;
}
}
public void Dispose()
{
if (_disposed || _abandoned)
return;
if (_operationActive)
throw new InvalidOperationException("The retained UI lease is changing state.");
_operationActive = true;
try
{
if (_runtime is not null)
{
_disposeRuntime!();
if (_runtimeDisposalComplete?.Invoke() != true)
throw new InvalidOperationException(
"The retained UI runtime returned without completing disposal.");
if (_hostDisposalComplete?.Invoke() != true)
throw new InvalidOperationException(
"The retained UI runtime completed without retiring its host.");
}
else if (_host is not null)
{
_disposeHost!();
if (_hostDisposalComplete?.Invoke() != true)
throw new InvalidOperationException(
"The retained UI host returned without completing disposal.");
}
_runtime = null;
_disposeRuntime = null;
_runtimeDisposalComplete = null;
_host = null;
_quiesceInput = null;
_deactivateInput = null;
_disposeHost = null;
_hostDisposalComplete = null;
_disposalFailed = false;
_disposed = true;
}
catch
{
_disposalFailed = true;
throw;
}
finally
{
_operationActive = false;
}
}
public void AbandonAfterTerminalFailure()
{
if (_disposed || _abandoned)
return;
if (_operationActive)
throw new InvalidOperationException("The retained UI lease is changing state.");
if (!_disposalFailed)
throw new InvalidOperationException(
"Retained UI resources may be abandoned only after disposal failed.");
// Keep the exact references rooted. Checkpoint J owns the final native
// fallback/reporting policy; abandonment must not turn this into an
// unowned local resource.
_abandoned = true;
}
private void ThrowIfUnavailable()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_abandoned)
throw new InvalidOperationException("The retained UI lease was abandoned.");
}
}

View file

@ -56,6 +56,8 @@ public sealed class UiHost : System.IDisposable
private bool _disposeRequested;
private bool _disposed;
internal bool IsDisposalComplete => _disposed;
public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null)
: this(gl, shaderDir, defaultFont, new HostQuiescenceGate())
{
@ -67,9 +69,9 @@ public sealed class UiHost : System.IDisposable
BitmapFont? defaultFont,
HostQuiescenceGate quiescence)
{
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
TextRenderer = new TextRenderer(gl, shaderDir);
DefaultFont = defaultFont;
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
}
// ── Per-frame ──────────────────────────────────────────────────────