Skip to content

Fix that the list Switch operator drops completion and throws errors - #1139

Merged
dwcullop merged 4 commits into
reactivemarbles:mainfrom
dwcullop:bugfix/list_switch_terminal_events
Aug 1, 2026
Merged

Fix that the list Switch operator drops completion and throws errors#1139
dwcullop merged 4 commits into
reactivemarbles:mainfrom
dwcullop:bugfix/list_switch_terminal_events

Conversation

@dwcullop

@dwcullop dwcullop commented Jul 25, 2026

Copy link
Copy Markdown
Member

Fixes #1138.

The list Switch operator dropped completion, threw errors instead of delivering them, and routed delivery through a lock.

Dropped completion, thrown errors

It relayed changes through a private SourceList and subscribed the observer to that:

var destination = new SourceList<T>();

var populator = Observable.Switch(_sources.Do(...)).Synchronize(locker).PopulateInto(destination);

var publisher = destination.Connect().SubscribeSafe(observer);

A relay only ends when it is disposed, so the terminal event of the source had nowhere to go. Completion was dropped outright. An error was worse than dropped: PopulateInto subscribes without an error handler, so Rx rethrew it out of the subscription, on whatever thread happened to be writing to the source rather than at the subscription.

That is a step worse than the cache operator (#1137), which at least carried errors across by hand.

The lock

The obvious fix is to let Observable.Switch sit between the sources and the observer and propagate terminal events on its own. That is wrong here, and it took a measurement to see why.

Observable.Switch holds its gate for the whole of the downstream OnNext call. A pipeline that crosses into another collection runs that work under the gate, and a producer on another thread blocks behind it. That is precisely the cross cache deadlock shape the delivery queue exists to avoid, and the operator on main avoids it today by keeping the observer on the far side of the relay list.

Measured with a subscriber that blocks inside OnNext, writing to the source from another thread:

time to return
main 0 ms
via Observable.Switch 748 ms
this PR 1 ms

So the operator is now written by hand. A SerialDisposable holds the current inner subscription, and every subscription carries an identity, so a superseded source still delivering concurrently is dropped rather than applied on top of the state its replacement has already established. All state changes and all delivery go through the queue, which releases its lock before handing anything downstream, so a second producer enqueues and returns instead of waiting.

The Clear that removes the previous source's contribution is enqueued under the same lock acquisition that takes the new identity, which keeps it ordered against the changes that follow it.

Terminal semantics

Completes once the sources and the current inner have both completed. Fails as soon as either does. That matches Observable.Switch, which is what callers reasonably expect from something called Switch.

Tests

List/SwitchFixture:

  • CompletesWhenSourcesAndInnerComplete
  • DoesNotCompleteWhileInnerIsStillRunning
  • DoesNotCompleteWhenOnlyASupersededInnerCompletes
  • CompletesWhenSourcesAndInnerCompleteSynchronously
  • DeliversChangesEmittedBeforeSynchronousCompletion
  • PropagatesInnerErrorsRaisedSynchronously
  • IgnoresChangesFromASupersededSource
  • DoesNotHoldALockWhileDeliveringDownstream

That last one blocks a subscriber inside OnNext and asserts that writing to the source from another thread still returns. It fails against an Observable.Switch implementation and passes against this one, so the lock behaviour cannot quietly come back.

Note the list aggregator exposes Exception where the cache one exposes Error, which is why the error assertions read differently from #1137.

Notes

Rebased onto current main and squashed. Only List/Internal/Switch.cs and its fixture are touched.

The cache Switch has the same defects and is fixed the same way in #1137. The two are independent and can land in either order.

Behavioural change to a public operator: sequences that previously never terminated now terminate when their sources do, and an error that previously escaped as a thrown exception now arrives as OnError. main is on 10.0-preview.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes DynamicData’s list Switch operator to correctly propagate terminal events (completion + errors) so it matches Observable.Switch semantics, addressing #1138.

Changes:

  • Added a terminal signaling path in List/Internal/Switch to stop the output sequence when the active inner and outer sequences terminate, and to forward errors/completion.
  • Added list-level regression tests for outer/inner error propagation and completion semantics.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/DynamicData/List/Internal/Switch.cs Updates list Switch implementation to carry terminal events across the private SourceList<T> relay.
src/DynamicData.Tests/List/SwitchFixture.cs Adds regression tests validating error and completion behavior for list Switch.

Comment thread src/DynamicData/List/Internal/Switch.cs Outdated
Comment thread src/DynamicData/List/Internal/Switch.cs Outdated

@JakenVeina JakenVeina left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comments as the cache version: .SubscribeSafe() and Disposable.Create().

@JakenVeina
JakenVeina force-pushed the bugfix/list_switch_terminal_events branch 4 times, most recently from 8d636b4 to 5b9500b Compare July 31, 2026 04:33
dwcullop and others added 4 commits July 31, 2026 01:01
… without taking a lock

Switch relayed changes through a private SourceList and subscribed the observer to
that, so the terminal event of the source had nowhere to go. Completion was
dropped outright, and an error was rethrown out of the subscription rather than
delivered as OnError.

Switching is now explicit rather than delegated to Observable.Switch, which holds
its gate for the whole of the downstream OnNext call. A pipeline that crosses into
another collection runs that work under the gate, and a producer on another thread
blocks behind it, which is the cross cache deadlock shape the delivery queue
exists to avoid. Measured against a subscriber that blocks inside OnNext, writing
to the source from another thread took 748ms through Observable.Switch and 1ms
through the queue, which enqueues and returns.

A SerialDisposable holds the current inner subscription, and each one carries an
identity, so a superseded source still delivering concurrently is dropped rather
than applied on top of the state its replacement has already established. All
state changes and all delivery happen through the queue lock, which is released
before anything is handed downstream.

The result completes once the sources and the current inner have both completed,
and fails as soon as either does.
The list counterpart of the same gap in the cache operator: OnNext and
OnCompleted check the captured id against the active source, but OnError went
straight to the queue, so a late failure from a source already switched away
from could terminate the output.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Same correction as the cache side. The Subject version was disposed by
SerialDisposable before the failure was raised, so Rx suppressed it and the
test passed with or without the guard. RawAnonymousObservable delivers the
failure after the switch, which is the in-flight case the guard is for.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Matches what the cache version got in reactivemarbles#1137. SubscribeSafe on both the
outer and inner subscriptions, and an explicit teardown instead of
CompositeDisposable, which does not specify a disposal order. The queue has
to go first so any delivery in flight finishes before the subscriptions
feeding it are torn down.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9582bb33-26d3-4aa5-8dd7-57dc55304680
@JakenVeina
JakenVeina force-pushed the bugfix/list_switch_terminal_events branch from 5b9500b to e62ba6b Compare July 31, 2026 06:01
@dwcullop

Copy link
Copy Markdown
Member Author

Same comments as the cache version: .SubscribeSafe() and Disposable.Create().

Fixed.

@dwcullop
dwcullop merged commit eb4e30e into reactivemarbles:main Aug 1, 2026
2 checks passed
@dwcullop
dwcullop deleted the bugfix/list_switch_terminal_events branch August 1, 2026 18:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: List Switch drops completion and throws errors instead of delivering them

3 participants