← All source files

V13 / 7879e8b5740b

AsyncResourceOwner.cs

Lilith/Core/Kokoro/AsyncResourceOwner.cs

Unchanged from the preceding version
CS
namespace Lilith.Core.Kokoro;

/// <summary>Bounds callers' waits while retaining ownership until native work actually ends.</summary>
internal sealed class AsyncResourceOwner(Action cleanup, TimeSpan timeout, TimeSpan shutdownTimeout)
{
    private readonly object _sync = new();
    private readonly CancellationTokenSource _lifetime = new();
    private readonly HashSet<Task> _work = [];
    private bool _closing;
    private bool _faulted;
    private Task? _dispose;

    public bool IsClosing { get { lock (_sync) return _closing; } }
    public void WhileOpen(Action action) { lock (_sync) { if (!_closing) action(); } }

    public Task RunAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
    {
        Task work;
        lock (_sync)
        {
            ObjectDisposedException.ThrowIf(_closing, this);
            if (_faulted) throw new InvalidOperationException("Speech engine timed out; restart the agent before retrying speech.");
            ct.ThrowIfCancellationRequested();
            work = Task.Run(async () =>
            {
                using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _lifetime.Token);
                await action(linked.Token);
            });
            _work.Add(work);
        }
        _ = work.ContinueWith(completed =>
        {
            _ = completed.Exception; // Observe errors even if the caller stopped waiting.
            lock (_sync) _work.Remove(completed);
        }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
        return WaitAsync(work, ct);
    }

    private async Task WaitAsync(Task work, CancellationToken ct)
    {
        try { await work.WaitAsync(timeout, ct); }
        catch (TimeoutException)
        {
            lock (_sync)
            {
                _faulted = true;
                if (!_closing) _lifetime.Cancel();
            }
            throw new TimeoutException("Kokoro operation timed out. Native work still owns its resources until it finishes.");
        }
    }

    public Task DisposeAsync()
    {
        lock (_sync)
        {
            if (_dispose is not null) return _dispose;
            _closing = true;
            _lifetime.Cancel();
            Task[] pending = [.. _work];
            _dispose = DisposeCoreAsync(pending);
            return _dispose;
        }
    }

    private async Task DisposeCoreAsync(Task[] pending)
    {
        var cleanupTask = Task.Run(async () =>
        {
            try { await Task.WhenAll(pending); }
            catch { /* Completion, including failure, releases ownership. */ }
            finally { try { cleanup(); } finally { _lifetime.Dispose(); } }
        });
        // If native work is stuck, cleanup remains attached to it and never frees live resources.
        try { await cleanupTask.WaitAsync(shutdownTimeout); }
        catch (TimeoutException)
        {
            _ = cleanupTask.ContinueWith(task => _ = task.Exception, TaskScheduler.Default);
        }
    }
}