dotnet/reactive · error · InvalidOperationException

Disposable already assigned.

Error message

Disposable already assigned.

What it means

SingleAssignmentAsyncDisposable.AssignAsync throws InvalidOperationException("Disposable already assigned.") when AssignAsync is called a second time while the disposable is neither unset nor disposed. The CompareExchange observes a non-null old value that is not the Disposed sentinel, meaning a second disposable is being attached to a single-assignment slot — a contract violation the library fails fast on.

Solutions

  1. Create a new SingleAssignmentAsyncDisposable per subscription/assignment instead of reusing one instance
  2. Serialize the assignment (lock, semaphore, or assign-once flag) so only the first caller assigns
  3. Check whether assignment already happened before calling AssignAsync and skip the second call
  4. If re-assignment is intentional, dispose the old single-assignment disposable first (after disposal, AssignAsync disposes the new one without throwing) or use a different container (e.g. SerialAsyncDisposable) that supports replacement

Example fix

// before
await sink.Disposable.AssignAsync(d1);
await sink.Disposable.AssignAsync(d2); // throws: already assigned
// after
await sink.Disposable.AssignAsync(d1);
if (!assigned)
{
    assigned = true;
    await sink.Disposable.AssignAsync(d2);
}
// or, when replacement is intended, use SerialAsyncDisposable:
await serial.AssignAsync(d2); // replaces d1 safely
Defensive patterns

Strategy: validation

Validate before calling

// allow assignment only once per slot, or after disposal
if (Interlocked.Exchange(ref assignedFlag, 1) == 0)
{
    await single.AssignAsync(d);
}
else
{
    await d.DisposeAsync(); // dispose the surplus disposable instead of throwing
}

Type guard

bool CanAssignNow() => assignedFlag == 0;

Try / catch

try
{
    await single.AssignAsync(d);
}
catch (InvalidOperationException ex) when (ex.Message == "Disposable already assigned.")
{
    // double assignment: dispose the redundant disposable and keep the first one
    await d.DisposeAsync();
}

Prevention

When it happens

Trigger: Calling AssignAsync twice on the same instance; races between concurrent tasks (firstTask/secondTask patterns) that both assign; operator sinks (Append, Buffer, DoWhile) re-subscribing or re-entering the assignment path after the single-assignment disposable was already populated and not yet disposed.

Common situations: Re-running a query builder that shares one SingleAssignmentAsyncDisposable across subscriptions; a race where two concurrent async flows both try to register their cleanup; forgetting to create a fresh sink per subscription in a cold observable.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of dotnet/reactive@94b5d5ab91 (2026-09-15). Data as JSON: /api/errors/5a1e022b88fea59a. Report an issue: GitHub.

Appendix: source

Thrown at AsyncRx.NET/System.Reactive.Async/Disposables/SingleAssignmentAsyncDisposable.cs:27

{
    public sealed class SingleAssignmentAsyncDisposable : IAsyncDisposable
    {
        private static readonly IAsyncDisposable Disposed = AsyncDisposable.Create(() => default);

        private IAsyncDisposable _disposable;

        public async ValueTask AssignAsync(IAsyncDisposable disposable)
        {
            if (disposable == null)
                throw new ArgumentNullException(nameof(disposable));

            var old = Interlocked.CompareExchange(ref _disposable, disposable, null);

            if (old == null)
                return;

            if (old != Disposed)
                throw new InvalidOperationException("Disposable already assigned.");

            await disposable.DisposeAsync().ConfigureAwait(false);
        }

        public ValueTask DisposeAsync()
        {
            return Interlocked.Exchange(ref _disposable, Disposed)?.DisposeAsync() ?? default;
        }
    }
}

View on GitHub (pinned to 94b5d5ab91)