Skip to content
Merged
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
3 changes: 3 additions & 0 deletions src/AsyncNavigation.Floating/ViewPlacementService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public async Task<IFloatingViewSession> FloatAsync(
{
ArgumentException.ThrowIfNullOrWhiteSpace(regionName);
cancellationToken.ThrowIfCancellationRequested();
using var flow = _placement.EnsureFlow();
using var lease = await _placement.EnterAsync(regionName, cancellationToken);

if (navigationId.HasValue &&
Expand Down Expand Up @@ -137,6 +138,7 @@ public bool TryGetSession(Guid sessionId, out IFloatingViewSession? session)

internal async Task RestoreCoreAsync(FloatingViewSession session, CancellationToken cancellationToken)
{
using var flow = _placement.EnsureFlow();
using var lease = await _placement.EnterAsync(session.OriginRegionName, cancellationToken);
if (!session.OriginRegion.TryGetTarget(out var originalRegion) ||
!_regionManager.TryGetRegion(session.OriginRegionName, out var region) ||
Expand Down Expand Up @@ -198,6 +200,7 @@ async Task RollbackContent()

internal async Task CloseCoreAsync(FloatingViewSession session, CancellationToken cancellationToken)
{
using var flow = _placement.EnsureFlow();
using var lease = await _placement.EnterAsync(session.OriginRegionName, cancellationToken);
await session.Host.SetContentAsync(null, cancellationToken);
try
Expand Down
2 changes: 2 additions & 0 deletions src/AsyncNavigation/RegionBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ Task<bool> IRegion.CanGoBackAsync()

public async Task<NavigationResult> GoBackAsync(CancellationToken cancellationToken = default)
{
using var flow = _placement?.EnsureFlow();
using var lease = _placement is null ? null : await _placement.EnterAsync(Name, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
var navigationContext = _navigationHistory.GoBack() ?? throw new NavigationException("Cannot go back!");
Expand Down Expand Up @@ -102,6 +103,7 @@ Task<bool> IRegion.CanGoForwardAsync()

public async Task<NavigationResult> GoForwardAsync(CancellationToken cancellationToken = default)
{
using var flow = _placement?.EnsureFlow();
using var lease = _placement is null ? null : await _placement.EnterAsync(Name, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
var navigationContext = _navigationHistory.GoForward() ?? throw new NavigationException("Cannot go forward!");
Expand Down
5 changes: 5 additions & 0 deletions src/AsyncNavigation/RegionNavigationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,15 @@ public RegionNavigationService(T regionPresenter, IServiceProvider serviceProvid
public async Task RequestNavigateAsync(NavigationContext navigationContext, Action? onCompleted = null, bool coordinatePlacement = true)
{
navigationContext.ActivatedExternally = false;
// Set up the indicator host before anything that could throw (job scheduling, lease
// acquisition), so the catch block below can always show an error indicator - not just
// when the navigation got far enough to reach CreateNavigateTask's own Setup() call.
_regionIndicatorManager.Setup(navigationContext, _regionPresenter.IsSinglePageRegion);
try
{
await _navigationJobScheduler.RunJobAsync(navigationContext, async context =>
{
using var flow = !coordinatePlacement || _placement is null ? null : _placement.EnsureFlow();
using var lease = !coordinatePlacement || _placement is null ? null :
await _placement.EnterAsync(context.RegionName, context.CancellationToken);
await CreateNavigateTask(context);
Expand Down
95 changes: 64 additions & 31 deletions src/AsyncNavigation/ViewPlacementCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,49 +9,71 @@ public sealed class ViewPlacementCoordinator
private readonly ConcurrentDictionary<string, RegionGate> _gates = new();
private readonly ConcurrentDictionary<IView, Func<CancellationToken, Task>> _floating = new(ReferenceEqualityComparer.Instance);

// Identifies "this logical operation" so a nested EnterAsync call on the same region (e.g. a
// navigation callback that triggers a placement change) can recognize it already holds the
// lease instead of deadlocking against itself. Must be established by EnsureFlow() - called
// synchronously, before any await - at every top-level entry point (RequestNavigateAsync,
// GoBackAsync/GoForwardAsync, FloatAsync, RestoreCoreAsync, CloseCoreAsync): an AsyncLocal
// write made inside a called async method never becomes visible to that method's own caller
// once it returns (the compiler-generated state machine restores the caller's captured
// ExecutionContext on resume), even when the method never truly suspends. Establishing the id
// in the entry point's own frame sidesteps that entirely - the value then flows forward
// through every await *it* performs and everything it calls, which is the direction
// AsyncLocal always propagates correctly.
private readonly AsyncLocal<Guid> _flowId = new();

/// <summary>
/// Marks the start of one logical placement operation so nested <see cref="EnterAsync"/>
/// calls on the same region can be recognized as reentrant. A no-op when already nested
/// inside an ancestor's flow (the ambient id is left alone). Callers should wrap their whole
/// operation: <c>using var flow = coordinator.EnsureFlow(); using var lease = await
/// coordinator.EnterAsync(...);</c>.
/// </summary>
public IDisposable EnsureFlow()
{
if (_flowId.Value != Guid.Empty)
return NoopScope.Instance;
_flowId.Value = Guid.NewGuid();
return new FlowScope(this);
}

/// <summary>
/// Acquires the placement lease for a region. Reentrant within the same logical async
/// call chain: code that already holds the lease (e.g. a navigation callback that in turn
/// triggers a placement change on the same region) re-enters immediately instead of
/// deadlocking against itself.
/// Acquires the placement lease for a region. Reentrant within the same <see cref="EnsureFlow"/>
/// scope: code that already holds the lease re-enters immediately instead of deadlocking
/// against itself.
/// </summary>
/// <remarks>
/// Deliberately not an `async` method: AsyncLocal writes made inside an async method are
/// invisible to its caller once it returns (the compiler-generated state machine restores
/// the caller's captured ExecutionContext), even when the method never actually suspends.
/// The uncontended/reentrant fast paths below write <see cref="RegionGate.Depth"/> directly
/// in this plain method body so the mark stays visible to the caller's subsequent code -
/// which is what lets a nested re-entry on the same region see it. Only the genuinely
/// contended wait is delegated to an async helper, where that guarantee no longer holds.
/// </remarks>
public Task<IDisposable> EnterAsync(string regionName, CancellationToken cancellationToken = default)
{
var gate = _gates.GetOrAdd(regionName, _ => new RegionGate());
if (gate.Depth.Value > 0)
{
gate.Depth.Value++;
var flowId = _flowId.Value;
if (flowId != Guid.Empty && gate.HolderFlowId == flowId)
return Task.FromResult<IDisposable>(new Lease(gate, reentrant: true));
}

var wait = gate.Semaphore.WaitAsync(cancellationToken);
if (wait.IsCompletedSuccessfully)
{
gate.Depth.Value = 1;
gate.HolderFlowId = flowId;
return Task.FromResult<IDisposable>(new Lease(gate, reentrant: false));
}
return EnterSlowAsync(gate, wait);
return EnterSlowAsync(gate, wait, flowId);
}

private static async Task<IDisposable> EnterSlowAsync(RegionGate gate, Task wait)
private static async Task<IDisposable> EnterSlowAsync(RegionGate gate, Task wait, Guid flowId)
{
await wait;
gate.Depth.Value = 1;
// A plain field write on the shared gate, not an AsyncLocal - ordinary object state isn't
// subject to the ExecutionContext isolation described above, so it's fine that this runs
// inside an async method the caller merely awaits.
gate.HolderFlowId = flowId;
return new Lease(gate, reentrant: false);
}

/// <summary>Returns whether the current logical async call chain already holds the placement lease for the region.</summary>
public bool IsHeld(string regionName) =>
_gates.TryGetValue(regionName, out var gate) && gate.Depth.Value > 0;
/// <summary>Returns whether the current EnsureFlow scope already holds the placement lease for the region.</summary>
public bool IsHeld(string regionName)
{
var flowId = _flowId.Value;
return flowId != Guid.Empty && _gates.TryGetValue(regionName, out var gate) && gate.HolderFlowId == flowId;
}

public bool IsFloating(IView view) => _floating.ContainsKey(view);

Expand All @@ -73,23 +95,34 @@ public async Task<bool> TryActivateAsync(IView view, CancellationToken cancellat
private sealed class RegionGate
{
public readonly SemaphoreSlim Semaphore = new(1, 1);
public readonly AsyncLocal<int> Depth = new();
public Guid HolderFlowId;
}

private sealed class Lease(RegionGate gate, bool reentrant) : IDisposable
{
private RegionGate? _gate = gate;
public void Dispose()
{
var g = Interlocked.Exchange(ref _gate, null);
if (g is null) return;
if (reentrant)
g.Depth.Value--;
else
{
g.Depth.Value = 0;
g.Semaphore.Release();
_gate = null;
return;
}
var g = Interlocked.Exchange(ref _gate, null);
if (g is null) return;
g.HolderFlowId = Guid.Empty;
g.Semaphore.Release();
}
}

private sealed class NoopScope : IDisposable
{
public static readonly NoopScope Instance = new();
public void Dispose() { }
}

private sealed class FlowScope(ViewPlacementCoordinator owner) : IDisposable
{
public void Dispose() => owner._flowId.Value = Guid.Empty;
}
}
22 changes: 15 additions & 7 deletions src/AsyncNavigation/WeakUnloadObserver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,10 @@ internal sealed class WeakUnloadObserver
// INavigationAware (e.g. after floating and docking a view back) replaces the previous
// handler instead of stacking a second one that would fire alongside it.
private static readonly ConditionalWeakTable<INavigationAware, AsyncEventHandler<AsyncEventArgs>> _subscriptions = new();
private static readonly object _subscriptionsLock = new();

public static void Subscribe(INavigationAware navigationAware, Action<INavigationAware> onUnloadCallback)
{
if (_subscriptions.TryGetValue(navigationAware, out var previousHandler))
{
navigationAware.AsyncRequestUnloadEvent -= previousHandler;
_subscriptions.Remove(navigationAware);
}

var weakReference = new WeakReference<INavigationAware>(navigationAware);

async Task HandleRequestUnloadAsync(object? sender, AsyncEventArgs args)
Expand All @@ -40,7 +35,20 @@ async Task HandleRequestUnloadAsync(object? sender, AsyncEventArgs args)
onUnloadCallback?.Invoke(target);
}

_subscriptions.Add(navigationAware, HandleRequestUnloadAsync);
// TryGetValue/Remove/Add aren't individually atomic against each other, and
// ConditionalWeakTable.Add throws on a duplicate key - serialize the read-modify-write
// so two concurrent Subscribe calls for the same instance can't both pass the
// TryGetValue check and race on Add.
AsyncEventHandler<AsyncEventArgs>? previousHandler;
lock (_subscriptionsLock)
{
_subscriptions.TryGetValue(navigationAware, out previousHandler);
if (previousHandler is not null)
_subscriptions.Remove(navigationAware);
_subscriptions.Add(navigationAware, HandleRequestUnloadAsync);
}
if (previousHandler is not null)
navigationAware.AsyncRequestUnloadEvent -= previousHandler;
navigationAware.AsyncRequestUnloadEvent += HandleRequestUnloadAsync;
}
}
58 changes: 58 additions & 0 deletions tests/AsyncNavigation.Tests/ViewPlacementCoordinatorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
namespace AsyncNavigation.Tests;

public sealed class ViewPlacementCoordinatorTests
{
[Fact]
public async Task EnterAsync_ReentrantFastPath_DoesNotDeadlock()
{
var coordinator = new ViewPlacementCoordinator();

using var flow = coordinator.EnsureFlow();
using var outer = await coordinator.EnterAsync("region");

Assert.True(coordinator.IsHeld("region"));

var inner = await coordinator.EnterAsync("region").WaitAsync(TimeSpan.FromSeconds(2));
inner.Dispose();

Assert.True(coordinator.IsHeld("region"));
}

[Fact]
public async Task EnterAsync_AfterContendedAcquire_IsHeldAndReentrancyBothWork()
{
var coordinator = new ViewPlacementCoordinator();

// Flow A takes the lease on the fast (uncontended) path and holds it open.
using var flowA = coordinator.EnsureFlow();
var leaseA = await coordinator.EnterAsync("region");

// Flow B's EnterAsync must genuinely wait - it cannot complete until Flow A releases -
// exercising the async EnterSlowAsync path rather than the synchronous fast path.
var enterBTask = Task.Run(async () =>
{
using var flow = coordinator.EnsureFlow();
var lease = await coordinator.EnterAsync("region");

// Regression check: after acquiring via the contended/slow path, this same logical
// flow must be recognized as the holder (previously IsHeld() spuriously read false
// here because the depth marker never escaped the async helper that set it).
Assert.True(coordinator.IsHeld("region"));

// Regression check: a nested EnterAsync call for the same region, from the same flow
// that just acquired it via the slow path, must be reentrant - not try to
// re-acquire the same semaphore and deadlock against itself.
var nested = await coordinator.EnterAsync("region").WaitAsync(TimeSpan.FromSeconds(2));
nested.Dispose();

lease.Dispose();
});

// Give Flow B's EnterAsync a moment to actually start waiting on the semaphore before
// releasing Flow A, so it exercises the contended path rather than racing to be first.
await Task.Delay(100);
leaseA.Dispose();

await enterBTask.WaitAsync(TimeSpan.FromSeconds(5));
}
}
Loading