Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
Expand Down Expand Up @@ -151,6 +152,55 @@ public async Task When_EnumerateWhileCreatingValue_Then_Lock()
enumeration.Result.Should().Be(1, because: "we should have got the value created for UI thread (on which we have waited on)");
}

[TestMethod]
public void When_ProviderDisposed_Then_ValueFallsBackToBackground()
{
// A provider whose backing state has been disposed. This is what
// DispatcherQueueProvider's static ThreadLocal<IDispatcher> does once the assembly owning it
// is unloaded with its collectible AssemblyLoadContext: ThreadLocal disposes itself from its
// own finalizer, and every later read throws.
var sut = new DispatcherLocal<string>(
factory: d => d is TestDispatcher ui ? ui.Name : "background",
schedulersProvider: () => throw new ObjectDisposedException(nameof(ThreadLocal<IDispatcher>)));

// Must NOT throw: callers reach this from their own finalizers (removing an event handler
// from a bindable collection resolves the current dispatcher), and an exception escaping a
// finalizer is unrecoverable -- it terminates the process rather than failing one operation.
sut.Value.Should().Be(
"background",
because: "a disposed provider means no dispatcher, which is also the truthful answer on a finalizer thread");
}

[TestMethod]
public void When_ProviderDisposed_Then_TryGetValueDoesNotThrow()
{
using var ui = new TestDispatcher("ui");
var sut = new DispatcherLocal<string>(
factory: d => d is TestDispatcher named ? named.Name : "background",
schedulersProvider: () => throw new ObjectDisposedException(nameof(ThreadLocal<IDispatcher>)),
allowCreationFromAnotherThread: true);

// TryGetValue resolves the CURRENT dispatcher to decide whether creation is permitted, so it
// reads the provider even though the owner is passed explicitly.
sut.TryGetValue(ui, out var value).Should().BeTrue();
value.Should().Be("ui");
}

[TestMethod]
public void When_ProviderThrowsOtherError_Then_ItPropagates()
{
// Only ObjectDisposedException is treated as "no dispatcher". Any other failure to resolve a
// dispatcher is a real fault and must not be swallowed, or a misconfigured provider would
// silently degrade every consumer to background values.
var sut = new DispatcherLocal<string>(
factory: _ => "background",
schedulersProvider: () => throw new InvalidOperationException("provider is misconfigured"));

var resolve = () => sut.Value;

resolve.Should().Throw<InvalidOperationException>().WithMessage("provider is misconfigured");
}

private int CountValues<T>(DispatcherLocal<T> sut, bool includeBackground = true)
{
// note : This method MUST use ForEachValue for test When_EnumerateWhileCreatingValue_Then_Lock to be useful!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,26 @@ public static class DispatcherQueueProvider
/// Gets a dispatcher queue instance that will execute tasks serially on the current thread, or null if no such queue exists.
/// </summary>
/// <returns>The dispatcher associated to the current thread if the thread is a UI thread.</returns>
/// <remarks>
/// Returns null rather than throwing once <see cref="_value"/> has been disposed. Nothing disposes
/// it explicitly -- <see cref="ThreadLocal{T}"/> disposes itself from its own finalizer, which
/// runs once this static becomes collectible. That happens when the assembly owning it is loaded
/// into a collectible <c>AssemblyLoadContext</c> and the context is unloaded: the ThreadLocal is
/// then finalized alongside the objects that still call in here, in no defined order, and those
/// calls can come from finalizers. Throwing from a finalizer is unrecoverable and terminates the
/// process, whereas null is the ordinary answer for a non-UI thread.
/// </remarks>
public static IDispatcher? GetForCurrentThread()
=> _value.Value;
{
try
{
return _value.Value;
}
catch (ObjectDisposedException)
{
return null;
}
}

private static IDispatcher? CreateForCurrentThread()
=> DispatcherQueue.GetForCurrentThread() is { } dispatcher ? new Dispatcher(dispatcher) : null;
Expand Down
43 changes: 39 additions & 4 deletions src/Uno.Extensions.Reactive/Utils/Dispatching/DispatcherLocal.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public T Value
{
get
{
var current = _schedulersProvider();
var current = FindCurrentDispatcher();
var (hasValue, value, error) = GetValueCore(current, current);

if (hasValue)
Expand All @@ -87,7 +87,7 @@ public T Value
throw new InvalidOperationException(error);
}
}
set => SetValueCore(_schedulersProvider(), value);
set => SetValueCore(FindCurrentDispatcher(), value);
}

/// <summary>
Expand All @@ -97,7 +97,7 @@ public T GetValue(IDispatcher scheduler)
{
var (hasValue, value, error) = GetValueCore(
owner: scheduler,
current: _schedulersProvider());
current: FindCurrentDispatcher());

if (hasValue)
{
Expand All @@ -119,11 +119,46 @@ public bool TryGetValue(IDispatcher scheduler, out T? value)
{
(var hasValue, value, _) = GetValueCore(
owner: scheduler,
current: _schedulersProvider());
current: FindCurrentDispatcher());

return hasValue;
}

/// <summary>
/// Resolves the dispatcher of the current thread, reporting "no dispatcher" instead of throwing
/// when the provider's backing state has already been disposed.
/// </summary>
/// <remarks>
/// A provider can legitimately be disposed while instances of this class are still reachable. The
/// UI package's <c>DispatcherQueueProvider</c> resolves the dispatcher from a static
/// <see cref="ThreadLocal{T}"/>, and <see cref="ThreadLocal{T}"/> disposes itself from its own
/// finalizer. When the assembly owning that static lives in a collectible
/// <c>AssemblyLoadContext</c>, unloading the context makes the static collectible, so the
/// ThreadLocal is finalized in the same pass as the objects that still hold a
/// <see cref="DispatcherLocal{T}"/> -- in no defined order.
///
/// Those objects reach this class from their own finalizers: removing an event handler from a
/// bindable collection resolves the current dispatcher to find the layer to unsubscribe from. If
/// the ThreadLocal was finalized first, that read threw <see cref="ObjectDisposedException"/> out
/// of a finalizer, which is unrecoverable and terminates the process.
///
/// Reporting null instead lets the caller take the background-value path, which is what a
/// finalizer thread would have got anyway -- it is not a UI thread, so there is no dispatcher to
/// find. Only <see cref="ObjectDisposedException"/> is treated this way: any other failure to
/// resolve a dispatcher is a real fault and still propagates.
/// </remarks>
private IDispatcher? FindCurrentDispatcher()
{
try
{
return _schedulersProvider();
}
catch (ObjectDisposedException)
{
return null;
}
}

private (bool hasValue, T? value, string? errorMessage) GetValueCore(IDispatcher? owner, IDispatcher? current)
{
var hasWriteAccess = false;
Expand Down