File: Api\ApplicationWorkspace.cs
Web Access
Project: src\src\Aspire.Hosting.Native.Server\Aspire.Hosting.Native.Server.csproj (Aspire.Hosting.Native.Server)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Hosting.Native.Diagnostics;
using Aspire.Hosting.Native.Model;
using Aspire.Hosting.Native.Runtime;
 
namespace Aspire.Hosting.Native.Api;
 
/// <summary>Owns an application runtime independently of AppHost connections and composition revisions.</summary>
[AspireExport]
internal sealed class ApplicationWorkspace(RuntimeInvitations invitations) : ICapabilityLifetime
{
    private readonly Lock _gate = new();
    private readonly CompositionSession _owner = new(invitations);
    private Composition? _pending;
    private Composition? _committed;
    private ApplicationExecution? _execution;
    private bool _applying;
    private bool _closed;
    private bool _retired;
    private Exception? _applyFailure;
    // A failed cleanup retains only retirement authority. Other workspace
    // operations reject the closed state through EnsureAvailable.
    public bool IsRevoked => Volatile.Read(ref _retired);
 
    /// <summary>Delegates access to a subsequent AppHost connection through a one-use invitation.</summary>
    [AspireExport]
    public string InviteApplicationWorkspace()
    {
        lock (_gate)
        {
            EnsureAvailable();
 
            return invitations.Issue(this, () => this);
        }
    }
 
    /// <summary>Begins an isolated desired-model revision while the committed application keeps running.</summary>
    [AspireExport]
    public Composition BeginRevision()
    {
        lock (_gate)
        {
            EnsureAvailable();
            if (_pending is not null)
            {
                throw new InvalidOperationException("An application revision is already being composed.");
            }
            _pending = new Composition(_owner, new ApplicationModel(invitations.Options.TimeProvider)) { Workspace = this };
 
            return _pending;
        }
    }
 
    /// <summary>Discards an uncommitted revision without changing running resources.</summary>
    [AspireExport]
    public void AbortRevision(Composition revision)
    {
        lock (_gate)
        {
            EnsureAvailable();
            ValidatePending(revision);
            DiscardRevision(revision);
        }
    }
 
    internal void DiscardRevision(Composition revision)
    {
        lock (_gate)
        {
            if (_applying || !ReferenceEquals(_pending, revision))
            {
                return;
            }
            revision.MarkRetired();
            revision.Model.Dispose();
            _pending = null;
        }
    }
 
    /// <summary>Commits desired declarations, retains compatible resource executions, and awaits removed workload cleanup.</summary>
    [AspireExport]
    public async Task CommitRevision(Composition revision)
    {
        Task cleanup;
        using var operation = new NativeDiagnostics.Operation("runtime.revision.commit", null, revision.Model.GenerationId, invitations.Options.TimeProvider);
        lock (_gate)
        {
            EnsureAvailable();
            ValidatePending(revision);
            var declarations = revision.Model.Seal();
            RuntimeGeneration runtime;
            if (_execution is null)
            {
                runtime = new RuntimeGeneration(declarations, invitations.Executor, invitations.Options);
                invitations.Track(runtime);
                cleanup = Task.CompletedTask;
            }
            else
            {
                runtime = _execution.Runtime;
                cleanup = runtime.ApplyRevision(declarations);
            }
            _applying = true;
            _committed?.MarkRetired(retireExecution: false, providerCleanup: cleanup);
            if (_committed is { } previous)
            {
                cleanup = Task.WhenAll(cleanup, previous.ProviderRetirement);
            }
            _committed?.Model.Dispose();
            _committed = revision;
            _pending = null;
            _execution = revision.AdoptExecution(runtime);
        }
        try
        {
            await cleanup.ConfigureAwait(false);
            operation.Succeeded = true;
        }
        catch (Exception exception)
        {
            // The desired revision is committed even when external cleanup fails.
            // Surface the failure; never claim external side effects rolled back.
            operation.ErrorType = exception.GetType().FullName;
            lock (_gate)
            {
                _applyFailure = exception;
            }
            throw;
        }
        finally
        {
            lock (_gate)
            {
                _applying = false;
            }
        }
    }
 
    /// <summary>Obtains authoring authority for the currently committed runtime revision.</summary>
    [AspireExport]
    public ApplicationExecution GetApplicationExecution()
    {
        lock (_gate)
        {
            EnsureAvailable();
 
            return _execution ?? throw new InvalidOperationException("No application revision has been committed.");
        }
    }
 
    /// <summary>Explicitly retires the workspace and awaits cleanup independently of AppHost connection lifetime.</summary>
    [AspireExport]
    public async Task RetireApplicationWorkspace()
    {
        Close();
        if (_execution is not null)
        {
            await Task.WhenAll(_execution.Runtime.Completion,
                _committed?.ProviderRetirement ?? Task.CompletedTask).ConfigureAwait(false);
        }
        Volatile.Write(ref _retired, true);
    }
 
    internal void Close()
    {
        lock (_gate)
        {
            if (_closed)
            {
                // Runtime retirement retries retained workload deletions without
                // replaying provider callbacks or restoring authoring authority.
                _committed?.MarkRetired();
                return;
            }
            _closed = true;
            _pending?.MarkRetired();
            _pending?.Model.Dispose();
            _pending = null;
            _committed?.MarkRetired();
            _committed?.Model.Dispose();
        }
    }
 
    private void EnsureAvailable()
    {
        ObjectDisposedException.ThrowIf(_closed, this);
        if (_applyFailure is not null)
        {
            throw new InvalidOperationException("The committed revision failed to clean up obsolete workloads; retire the workspace before retrying.",
                _applyFailure);
        }
        if (_applying)
        {
            throw new InvalidOperationException("The previous revision is still being applied.");
        }
    }
 
    private void ValidatePending(Composition revision)
    {
        if (!ReferenceEquals(_pending, revision))
        {
            throw new ArgumentException("The revision is not staged in this workspace.", nameof(revision));
        }
    }
}