Skip to content

fix(reactive): treat a disposed dispatcher provider as no dispatcher - #3175

Open
nickrandolph wants to merge 2 commits into
servicing/7.4from
dev/nr/dispatcher-local-finalizer-safety
Open

fix(reactive): treat a disposed dispatcher provider as no dispatcher#3175
nickrandolph wants to merge 2 commits into
servicing/7.4from
dev/nr/dispatcher-local-finalizer-safety

Conversation

@nickrandolph

Copy link
Copy Markdown
Contributor

Fixes #3174 — read that issue for the full mechanism; this is the fix and its evidence.

Draft because the reproduction that motivated it is an end-to-end collectible-AssemblyLoadContext host that lives downstream, so CI here can only prove the unit-level behaviour. See Verification for exactly what is and is not established.

The bug in one paragraph

Removing an event handler from a BindableCollection resolves the current dispatcher so it can find that thread's DataLayer (BindableCollection.cs:239DispatcherLocal.Value_schedulersProvider()), and that resolution reads DispatcherQueueProvider's static ThreadLocal<IDispatcher>. ThreadLocal<T> disposes itself from its own finalizer, so once the assembly owning that static is unloaded with a collectible ALC, the read throws ObjectDisposedException. Removal is reached from a finalizer — ~ItemsSourceView() disposes its collection-changed listener, which unsubscribes — and an exception escaping a finalizer is unrecoverable: it terminates the process.

The change

DispatcherLocal (core) — every current-dispatcher resolution now goes through FindCurrentDispatcher(), which reports null on ObjectDisposedException. All four call sites are covered: Value get, Value set, GetValue, TryGetValue. (TryGetValue matters even though the owner is passed explicitly — it still reads the current dispatcher to decide whether creation from another thread is permitted.)

Null is the truthful answer here, not a degraded one: a finalizer thread is not a UI thread, so there is genuinely no dispatcher to find. DispatcherLocal already has the background-value path for exactly that case, and allowBackgroundValue is never overridden anywhere in this repo (default true, DispatcherLocal.cs:41; the only production construction is BindableCollection.cs:132, which passes only allowCreationFromAnotherThread: true) — so that path is always available. Had it been false, this fix would merely have swapped a fatal ObjectDisposedException for a fatal InvalidOperationException, which is why it is called out rather than assumed.

DispatcherQueueProvider (UI)GetForCurrentThread() guards the same way, so the provider cannot throw even for a consumer that resolves it directly rather than through DispatcherLocal. Defence in depth; the core fix is the load-bearing one.

Only ObjectDisposedException is treated this way. Any other resolution failure stays fatal — a misconfigured provider must not silently degrade every consumer to background values. There is a test for that specifically.

Chosen this way because DispatcherLocal is the layer that depends on "resolve the current dispatcher", it is reachable from every one of the eight removal paths in BindableCollection (lines 239, 245, 335, 338, 344, 347, 353, 356 — VectorChanged and the selection handlers resolve on removal too, so CollectionChanged is not the only exposure), and it takes an injectable provider, which makes the fix testable without disposing a process-wide static from a test.

Deliberately not done here

Removal should arguably not resolve a dispatcher at all. DispatcherLocal.ForEachValue already iterates existing layers without resolving or creating anything, so the removal accessors could apply across all layers and be naturally idempotent — which would additionally fix removing a handler from a different thread than the one that added it, and would avoid constructing a DataLayer on a finalizer thread.

That is a behavioural change to event-removal semantics across eight accessors, including token-based overloads whose tolerance for unknown tokens I have not audited. It does not belong on a servicing branch. Happy to open it against main if you want it.

Verification

Red/green, run both ways:

Run Result
3 new tests with the fix 6 passed / 0 failed (Given_DispatcherLocal)
Same tests with DispatcherLocal.cs reverted, tests kept 2 failed / 4 passedWhen_ProviderDisposed_Then_ValueFallsBackToBackground, When_ProviderDisposed_Then_TryGetValueDoesNotThrow
Full Uno.Extensions.Reactive.Tests 1422 passed / 0 failed / 19 skipped
dotnet build of the test project 0 errors

The third new test — When_ProviderThrowsOtherError_Then_ItPropagates — passes in both directions by design: it asserts the exception types that must keep throwing, so it guards the narrowness of the catch rather than the fix itself.

Not established here, and worth being plain about: no end-to-end proof that the process no longer dies. That needs a host loading Uno.Extensions.Reactive.UI into a collectible ALC, unloading it, and forcing finalizers — the setup described in the issue. The unit tests reproduce the provider state that causes it (a provider that throws ObjectDisposedException) and prove the resolution path now tolerates it; they do not reproduce the ALC teardown itself. I will confirm end-to-end against a downstream host once this is in a 7.4-dev package and report back on the issue.

One compile detail, in case it comes up in review: the XML doc on FindCurrentDispatcher refers to DispatcherQueueProvider in prose rather than with a cref, because it lives in the UI assembly and core does not reference it — a cref fails the build with CS1574.

Removing an event handler from a BindableCollection resolves the current
dispatcher so it can find the thread's DataLayer, and that resolution reads
DispatcherQueueProvider's static ThreadLocal<IDispatcher>. ThreadLocal
disposes itself from its own finalizer, so once the assembly owning that
static is unloaded with a collectible AssemblyLoadContext the read starts
throwing ObjectDisposedException.

Removal is reached FROM a finalizer (~ItemsSourceView disposes its
collection-changed listener, which unsubscribes), and an exception escaping a
finalizer is unrecoverable: it terminates the process rather than failing one
operation.

- DispatcherLocal routes every current-dispatcher resolution through
  FindCurrentDispatcher, which reports null on ObjectDisposedException. Null
  is the truthful answer, not a degraded one: a finalizer thread is not a UI
  thread and has no dispatcher to find, so the existing background-value path
  is correct for it. allowBackgroundValue is never overridden in this repo
  (default true), so that path is always available.
- DispatcherQueueProvider.GetForCurrentThread guards the same way, so the
  provider cannot throw even for a consumer that resolves it directly.
- Only ObjectDisposedException is treated this way. Any other failure to
  resolve a dispatcher stays fatal, or a misconfigured provider would silently
  degrade every consumer to background values -- asserted by a test.

Fixes #3174

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nickrandolph
nickrandolph marked this pull request as ready for review September 5, 2026 12:18
Copilot AI lite review requested due to automatic review settings September 5, 2026 12:18

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.

🟢 Approval recommended

The behavioral change is narrowly scoped, has targeted unit coverage, and only leaves minor doc wording nits to address.

Pull request overview

Addresses a crash scenario in the Reactive dispatching layer when the current-dispatcher provider has been disposed (notably when Uno.Extensions.Reactive.UI is loaded/unloaded via a collectible AssemblyLoadContext), by treating ObjectDisposedException during dispatcher resolution as “no dispatcher” and falling back to background-value behavior.

Changes:

  • Route all current-dispatcher resolution in DispatcherLocal<T> through a new FindCurrentDispatcher() helper that returns null on ObjectDisposedException.
  • Add the same ObjectDisposedExceptionnull guard to DispatcherQueueProvider.GetForCurrentThread() for defense in depth.
  • Add unit tests proving ObjectDisposedException falls back safely while other provider failures still propagate.
File summaries
File Description
src/Uno.Extensions.Reactive/Utils/Dispatching/DispatcherLocal.cs Adds FindCurrentDispatcher() and uses it for all current-dispatcher lookups to avoid ObjectDisposedException escaping finalizer-driven code paths.
src/Uno.Extensions.Reactive.UI/Utils/Dispatching/DispatcherQueueProvider.cs Guards ThreadLocal access so GetForCurrentThread() returns null instead of throwing after disposal.
src/Uno.Extensions.Reactive.Tests/Utils/Dispatching/Given_DispatcherLocal.cs Adds regression tests covering disposed-provider fallback and “other exceptions still throw” behavior.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Uno.Extensions.Reactive.UI/Utils/Dispatching/DispatcherQueueProvider.cs Outdated
Comment thread src/Uno.Extensions.Reactive/Utils/Dispatching/DispatcherLocal.cs Outdated
Both remarks blocks said a static becomes "collectable"; the runtime's own
term is "collectible" (AssemblyLoadContext.IsCollectible), which is what a
reader will search for.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

2 participants