Use Medalion's mutex
MODIFY
WalletWasabi.Tests/StoreTests.cs
WalletWasabi.Tests/StoreTests.cs
@@ -386,9 +386,6 @@ namespace WalletWasabi.Tests
[Fact]
public async Task IoTestsAsync()
{
- // ToDo: This test terminates on Linux in an unexplainable way: https://github.com/zkSNACKs/WalletWasabi/pull/1405#issuecomment-490870883
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return;
-
var file1 = Path.Combine(Global.DataDir, nameof(IoTestsAsync), $"file.dat");
IoManager ioman1 = new IoManager(file1);

ADD
WalletWasabi/Medallion/Threading/DistributedLockHelpers.cs
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.ExceptionServices;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Medallion.Threading
+{
+ internal static class DistributedLockHelpers
+ {
+ public static int ToInt32Timeout(this TimeSpan timeout, string paramName = null)
+ {
+ // based on http://referencesource.microsoft.com/#mscorlib/system/threading/Tasks/Task.cs,959427ac16fa52fa
+
+ var totalMilliseconds = (long)timeout.TotalMilliseconds;
+ if (totalMilliseconds < -1 || totalMilliseconds > int.MaxValue)
+ {
+ throw new ArgumentOutOfRangeException(paramName ?? "timeout");
+ }
+
+ return (int)totalMilliseconds;
+ }
+
+ public static Task<IDisposable> AcquireAsync(IDistributedLock @lock, TimeSpan? timeout, CancellationToken cancellationToken)
+ {
+ var tryAcquireTask = @lock.TryAcquireAsync(timeout ?? Timeout.InfiniteTimeSpan, cancellationToken);
+ return ValidateTryAcquireResultAsync(tryAcquireTask, timeout);
+ }
+
+ public static IDisposable Acquire(IDistributedLock @lock, TimeSpan? timeout, CancellationToken cancellationToken)
+ {
+ var handle = @lock.TryAcquire(timeout ?? Timeout.InfiniteTimeSpan, cancellationToken);
+ ValidateTryAcquireResult(handle, timeout);
+
+ return handle;
+ }
+
+ public static async Task<THandle> ValidateTryAcquireResultAsync<THandle>(Task<THandle> tryAcquireTask, TimeSpan? timeout)
+ where THandle : class, IDisposable
+ {
+ var handle = await tryAcquireTask;
+ ValidateTryAcquireResult(handle, timeout);
+
+ return handle;
+ }
+
+ public static void ValidateTryAcquireResult(IDisposable handle, TimeSpan? timeout) =>
+ ValidateTryAcquireResult(succeeded: handle != null, timeout: timeout);
+
+ public static void ValidateTryAcquireResult(bool succeeded, TimeSpan? timeout)
+ {
+ if (!succeeded)
+ {
+ if (timeout.HasValue && timeout >= TimeSpan.Zero)
+ throw new TimeoutException("Timeout exceeded when trying to acquire the lock");
+
+ // should never get here
+ throw new InvalidOperationException("Failed to acquire the lock");
+ }
+ }
+
+ public static IDisposable TryAcquireWithAsyncCancellation(IDistributedLock @lock, TimeSpan timeout, CancellationToken cancellationToken)
+ {
+ var tryAcquireTask = @lock.TryAcquireAsync(timeout, cancellationToken);
+ try
+ {
+ return tryAcquireTask.Result;
+ }
+ catch (AggregateException ex)
+ {
+ // attempt to prevent the throwing of aggregate exceptions
+ if (ex.InnerExceptions.Count == 1)
+ {
+ // rethrow the inner exception without losing stack trace
+ ExceptionDispatchInfo.Capture(ex.InnerException).Throw();
+ }
+ throw; // otherwise just rethrow
+ }
+ }
+
+ public static string ToSafeLockName(string baseLockName, int maxNameLength, Func<string, string> convertToValidName)
+ {
+ if (baseLockName == null)
+ throw new ArgumentNullException("baseLockName");
+
+ var validBaseLockName = convertToValidName(baseLockName);
+ if (validBaseLockName == baseLockName && validBaseLockName.Length <= maxNameLength)
+ {
+ return baseLockName;
+ }
+
+ using (var sha = SHA512.Create())
+ {
+ var hash = Convert.ToBase64String(sha.ComputeHash(Encoding.UTF8.GetBytes(baseLockName)));
+
+ if (hash.Length >= maxNameLength)
+ {
+ return hash.Substring(0, length: maxNameLength);
+ }
+
+ var prefix = validBaseLockName.Substring(0, Math.Min(validBaseLockName.Length, maxNameLength - hash.Length));
+ return prefix + hash;
+ }
+ }
+ }
+}

ADD
WalletWasabi/Medallion/Threading/IDistributedLock.cs
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Medallion.Threading
+{
+ internal interface IDistributedLock
+ {
+ IDisposable TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default);
+
+ IDisposable Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default);
+
+ Task<IDisposable> TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default);
+
+ Task<IDisposable> AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default);
+ }
+}

ADD
WalletWasabi/Medallion/Threading/SystemDistributedLock.cs
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Security.AccessControl;
+using System.Security.Principal;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Medallion.Threading
+{
+ /// <summary>
+ /// Implements a system-/OS-scoped distributed lock using .NET <see cref="EventWaitHandle"/>s
+ /// </summary>
+ public sealed class SystemDistributedLock : IDistributedLock
+ {
+ private const string GlobalPrefix = @"Global\";
+ private static readonly TimeSpan DefaultAbandonmentCheckFrequency = TimeSpan.FromSeconds(2);
+
+ private readonly string LockName;
+ private readonly TimeSpan AbandonmentCheckFrequency;
+
+ /// <summary>
+ /// Creates an instance of <see cref="SystemDistributedLock"/> named <paramref name="lockName"/>.
+ /// <paramref name="abandonmentCheckFrequency"/> specifies how long the lock should wait before checking
+ /// if the underlying <see cref="EventWaitHandle"/> has been abandoned (defaults to 2 seconds)
+ /// </summary>
+ public SystemDistributedLock(string lockName, TimeSpan? abandonmentCheckFrequency = default)
+ {
+ // note that just Global\ is not a valid name
+ if (string.IsNullOrEmpty(lockName))
+ throw new ArgumentNullException("lockName is required");
+ if (lockName.Length > MaxLockNameLength)
+ throw new FormatException("lockName: must be at most " + MaxLockNameLength + " characters");
+ // from http://stackoverflow.com/questions/18392175/net-system-wide-eventwaithandle-name-allowed-characters
+ if (lockName.IndexOf('\\') >= 0)
+ throw new FormatException(@"lockName: must not contain '\'");
+
+ if (abandonmentCheckFrequency.HasValue)
+ {
+ // must be a valid timeout
+ var abandonmentCheckFrequencyMillis = abandonmentCheckFrequency.Value.ToInt32Timeout("abandonmentCheckFrequency");
+ if (abandonmentCheckFrequencyMillis == 0)
+ throw new ArgumentOutOfRangeException("abandonmentCheckFrequency: must be non-zero");
+ AbandonmentCheckFrequency = abandonmentCheckFrequency.Value;
+ }
+ else
+ {
+ AbandonmentCheckFrequency = DefaultAbandonmentCheckFrequency;
+ }
+
+ LockName = GlobalPrefix + lockName;
+ }
+
+ #region ---- Public API ----
+
+ /// <summary>
+ /// Attempts to acquire the lock synchronously. Usage:
+ /// <code>
+ /// using (var handle = myLock.TryAcquire(...))
+ /// {
+ /// if (handle != null) { /* we have the lock! */ }
+ /// }
+ /// // dispose releases the lock if we took it
+ /// </code>
+ /// </summary>
+ /// <param name="timeout">How long to wait before giving up on acquiring the lock. Defaults to 0</param>
+ /// <param name="cancellationToken">Specifies a token by which the wait can be canceled</param>
+ /// <returns>An <see cref="IDisposable"/> "handle" which can be used to release the lock, or null if the lock was not taken</returns>
+ public IDisposable TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default)
+ {
+ var timeoutMillis = timeout.ToInt32Timeout();
+ var abandonmentCheckFrequencyMillis = AbandonmentCheckFrequency.ToInt32Timeout();
+
+ var @event = CreateEvent();
+ var cleanup = true;
+ try
+ {
+ if (abandonmentCheckFrequencyMillis <= 0)
+ {
+ // no abandonment check: just acquire once
+ if (TryAcquireOnce(@event, timeoutMillis, cancellationToken))
+ {
+ cleanup = false;
+ return new EventScope(@event);
+ }
+ return null;
+ }
+
+ if (timeoutMillis < 0)
+ {
+ // infinite timeout: just loop forever with the abandonment check
+ while (true)
+ {
+ if (TryAcquireOnce(@event, abandonmentCheckFrequencyMillis, cancellationToken))
+ {
+ cleanup = false;
+ return new EventScope(@event);
+ }
+
+ // refresh the event in case it was abandoned by the original owner
+ @event.Dispose();
+ @event = CreateEvent();
+ }
+ }
+
+ // fixed timeout: loop in abandonment check chunks
+ var elapsedMillis = 0;
+ do
+ {
+ var nextWaitMillis = Math.Min(abandonmentCheckFrequencyMillis, timeoutMillis - elapsedMillis);
+ if (TryAcquireOnce(@event, nextWaitMillis, cancellationToken))
+ {
+ cleanup = false;
+ return new EventScope(@event);
+ }
+
+ elapsedMillis += nextWaitMillis;
+
+ // refresh the event in case it was abandoned by the original owner
+ @event.Dispose();
+ @event = CreateEvent();
+ }
+ while (elapsedMillis < timeoutMillis);
+
+ return null;
+ }
+ catch
+ {
+ // just in case we fail to create a scope or something
+ throw;
+ }
+ finally
+ {
+ if (cleanup)
+ {
+ @event.Dispose();
+ }
+ }
+ }
+
+ /// <summary>
+ /// Acquires the lock synchronously, failing with <see cref="TimeoutException"/> if the wait times out
+ /// <code>
+ /// using (myLock.Acquire(...))
+ /// {
+ /// // we have the lock
+ /// }
+ /// // dispose releases the lock
+ /// </code>
+ /// </summary>
+ /// <param name="timeout">How long to wait before giving up on acquiring the lock. Defaults to <see cref="Timeout.InfiniteTimeSpan"/></param>
+ /// <param name="cancellationToken">Specifies a token by which the wait can be canceled</param>
+ /// <returns>An <see cref="IDisposable"/> "handle" which can be used to release the lock</returns>
+ public IDisposable Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default)
+ {
+ return DistributedLockHelpers.Acquire(this, timeout, cancellationToken);
+ }
+
+ /// <summary>
+ /// Attempts to acquire the lock asynchronously. Usage:
+ /// <code>
+ /// using (var handle = await myLock.TryAcquireAsync(...))
+ /// {
+ /// if (handle != null) { /* we have the lock! */ }
+ /// }
+ /// // dispose releases the lock if we took it
+ /// </code>
+ /// </summary>
+ /// <param name="timeout">How long to wait before giving up on acquiring the lock. Defaults to 0</param>
+ /// <param name="cancellationToken">Specifies a token by which the wait can be canceled</param>
+ /// <returns>An <see cref="IDisposable"/> "handle" which can be used to release the lock, or null if the lock was not taken</returns>
+ public Task<IDisposable> TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default)
+ {
+ var timeoutMillis = timeout.ToInt32Timeout();
+
+ return InternalTryAcquireAsync(timeoutMillis, cancellationToken);
+ }
+
+ /// <summary>
+ /// Acquires the lock asynchronously, failing with <see cref="TimeoutException"/> if the wait times out
+ /// <code>
+ /// using (await myLock.AcquireAsync(...))
+ /// {
+ /// // we have the lock
+ /// }
+ /// // dispose releases the lock
+ /// </code>
+ /// </summary>
+ /// <param name="timeout">How long to wait before giving up on acquiring the lock. Defaults to <see cref="Timeout.InfiniteTimeSpan"/></param>
+ /// <param name="cancellationToken">Specifies a token by which the wait can be canceled</param>
+ /// <returns>An <see cref="IDisposable"/> "handle" which can be used to release the lock</returns>
+ public Task<IDisposable> AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default)
+ {
+ return DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken);
+ }
+
+ /// <summary>
+ /// The maximum allowed length for lock names
+ /// </summary>
+ // 260 based on LINQPad experimentation
+ public static int MaxLockNameLength => 260 - GlobalPrefix.Length;
+
+ /// <summary>
+ /// Given <paramref name="baseLockName"/>, constructs a lock name which is safe for use with <see cref="SystemDistributedLock"/>
+ /// </summary>
+ public static string GetSafeLockName(string baseLockName)
+ {
+ return DistributedLockHelpers.ToSafeLockName(baseLockName, MaxLockNameLength, s => s.Length == 0 ? "EMPTY" : s.Replace('\\', '_'));
+ }
+
+ #endregion ---- Public API ----
+
+ private async Task<IDisposable> InternalTryAcquireAsync(int timeoutMillis, CancellationToken cancellationToken)
+ {
+ var abandonmentCheckFrequencyMillis = AbandonmentCheckFrequency.ToInt32Timeout();
+
+ var @event = CreateEvent();
+ var cleanup = true;
+ try
+ {
+ if (abandonmentCheckFrequencyMillis <= 0)
+ {
+ // no abandonment check: just acquire once
+ if (await @event.WaitOneAsync(TimeSpan.FromMilliseconds(timeoutMillis), cancellationToken))
+ {
+ cleanup = false;
+ return new EventScope(@event);
+ }
+ return null;
+ }
+
+ if (timeoutMillis < 0)
+ {
+ // infinite timeout: just loop forever with the abandonment check
+ while (true)
+ {
+ if (await @event.WaitOneAsync(AbandonmentCheckFrequency, cancellationToken))
+ {
+ cleanup = false;
+ return new EventScope(@event);
+ }
+
+ // refresh the event in case it was abandoned by the original owner
+ @event.Dispose();
+ @event = CreateEvent();
+ }
+ }
+
+ // fixed timeout: loop in abandonment check chunks
+ var elapsedMillis = 0;
+ do
+ {
+ var nextWaitMillis = Math.Min(abandonmentCheckFrequencyMillis, timeoutMillis - elapsedMillis);
+ if (await @event.WaitOneAsync(TimeSpan.FromMilliseconds(nextWaitMillis), cancellationToken))
+ {
+ cleanup = false;
+ return new EventScope(@event);
+ }
+
+ elapsedMillis += nextWaitMillis;
+
+ // refresh the event in case it was abandoned by the original owner
+ @event.Dispose();
+ @event = CreateEvent();
+ }
+ while (elapsedMillis < timeoutMillis);
+
+ return null;
+ }
+ catch
+ {
+ // just in case we fail to create a scope or something
+ throw;
+ }
+ finally
+ {
+ if (cleanup)
+ {
+ @event.Dispose();
+ }
+ }
+ }
+
+ private static bool TryAcquireOnce(EventWaitHandle @event, int timeoutMillis, CancellationToken cancellationToken)
+ {
+ // cancellation case
+ if (cancellationToken.CanBeCanceled)
+ {
+ // ensures that if we are already canceled upon entering this method
+ // we will cancel, not wait
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // cancellable wait based on
+ // http://www.thomaslevesque.com/2015/06/04/async-and-cancellation-support-for-wait-handles/
+ var index = WaitHandle.WaitAny(new[] { @event, cancellationToken.WaitHandle }, timeoutMillis);
+ switch (index)
+ {
+ case WaitHandle.WaitTimeout: // timeout
+ @event.Dispose();
+ return false;
+
+ case 0: // event
+ return true;
+
+ default: // canceled
+ cancellationToken.ThrowIfCancellationRequested();
+ throw new InvalidOperationException("Should never get here");
+ }
+ }
+
+ // normal case
+ return @event.WaitOne(timeoutMillis);
+ }
+
+ private EventWaitHandle CreateEvent()
+ {
+ // based on http://stackoverflow.com/questions/2590334/creating-a-cross-process-eventwaithandle
+ var security = new EventWaitHandleSecurity();
+ // allow anyone to wait on and signal this lock
+ security.AddAccessRule(
+ new EventWaitHandleAccessRule(
+ new SecurityIdentifier(WellKnownSidType.WorldSid, domainSid: null),
+ EventWaitHandleRights.FullControl, // doesn't seem to work without this :-/
+ AccessControlType.Allow
+ )
+ );
+ var @event = new EventWaitHandle(
+ // if we create, start as unlocked
+ initialState: true,
+ // allow only one thread to hold the lock
+ mode: EventResetMode.AutoReset,
+ name: LockName,
+ createdNew: out _
+ );
+ @event.SetAccessControl(security);
+
+ return @event;
+ }
+
+ private sealed class EventScope : IDisposable
+ {
+ private EventWaitHandle _event;
+
+ public EventScope(EventWaitHandle @event)
+ {
+ _event = @event;
+ }
+
+ void IDisposable.Dispose()
+ {
+ var @event = Interlocked.Exchange(ref _event, null);
+ if (@event != null)
+ {
+ @event.Set(); // signal
+ @event.Dispose();
+ }
+ }
+ }
+ }
+}

ADD
WalletWasabi/Medallion/Threading/WaitHandleHelpers.cs
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Medallion.Threading
+{
+ internal static class WaitHandleHelpers
+ {
+ public static Task<bool> WaitOneAsync(this WaitHandle @this, TimeSpan timeout, CancellationToken cancellationToken)
+ {
+ if (@this == null)
+ throw new ArgumentNullException("this");
+ var timeoutMillis = timeout.ToInt32Timeout();
+
+ return WaitOneAsync(@this, timeoutMillis, cancellationToken);
+ }
+
+ // based on http://www.thomaslevesque.com/2015/06/04/async-and-cancellation-support-for-wait-handles/
+ private static async Task<bool> WaitOneAsync(WaitHandle handle, int timeoutMillis, CancellationToken cancellationToken)
+ {
+ RegisteredWaitHandle registeredHandle = null;
+ CancellationTokenRegistration tokenRegistration = default;
+ try
+ {
+ var taskCompletionSource = new TaskCompletionSource<bool>();
+ registeredHandle = ThreadPool.RegisterWaitForSingleObject(
+ handle,
+ (state, timedOut) => ((TaskCompletionSource<bool>)state).TrySetResult(!timedOut),
+ state: taskCompletionSource,
+ millisecondsTimeOutInterval: timeoutMillis,
+ executeOnlyOnce: true
+ );
+ tokenRegistration = cancellationToken.Register(
+ state => ((TaskCompletionSource<bool>)state).TrySetCanceled(),
+ state: taskCompletionSource
+ );
+ return await taskCompletionSource.Task;
+ }
+ finally
+ {
+ if (registeredHandle != null)
+ {
+ // this is different from the referenced site, but I think this is more correct:
+ // the handle passed to unregister is a handle to be signaled, not the one to unregister
+ // (that one is already captured by the registered handle). See
+ // http://referencesource.microsoft.com/#mscorlib/system/threading/threadpool.cs,065408fc096354fd
+ registeredHandle.Unregister(null);
+ }
+ tokenRegistration.Dispose();
+ }
+ }
+ }
+}

MODIFY
WalletWasabi/Nito/AsyncEx/AsyncMutex.cs
WalletWasabi/Nito/AsyncEx/AsyncMutex.cs
+using Medallion.Threading;
using System;
using System.Collections.Generic;
using System.IO;
@@ -11,17 +12,8 @@ namespace Nito.AsyncEx
public class AsyncMutex
{
private string ShortName { get; set; }
- private string FullName { get; set; }
-
- private Mutex Mutex { get; set; }
- private AsyncLock AsyncLock { get; set; }
-
- /// <summary>
- /// string: mutex name
- /// </summary>
- private static Dictionary<string, AsyncLock> AsyncLocks { get; } = new Dictionary<string, AsyncLock>();
-
- private static object AsyncLocksLock { get; } = new object();
+ private SystemDistributedLock SystemDistributedLock { get; set; }
+ private IDisposable SystemDistributedLockDisposable { get; set; }
public AsyncMutex(string name)
{
@@ -35,22 +27,8 @@ namespace Nito.AsyncEx
// and both are visible to all processes in the terminal server session.
// That is, the prefix names "Global\" and "Local\" describe the scope of the mutex name relative to terminal server sessions, not relative to processes.
ShortName = name;
- FullName = $"Global\\4AA0E5A2-A94F-4B92-B962-F2BBC7A68323-{name}";
- Mutex = null;
-
- // If we already have an asynclock with this fullname then just use it and don't create a new one.
- lock (AsyncLocksLock)
- {
- if (AsyncLocks.TryGetValue(FullName, out AsyncLock asyncLock))
- {
- AsyncLock = asyncLock;
- }
- else
- {
- AsyncLock = new AsyncLock();
- AsyncLocks.Add(FullName, AsyncLock);
- }
- }
+ SystemDistributedLock = new SystemDistributedLock($"4AA0E5A2-A94F-4B92-B962-F2BBC7A68323-{name}");
+ SystemDistributedLockDisposable = null;
}
public async Task<IDisposable> LockAsync(CancellationToken cancellationToken = default, int pollInterval = 100)
@@ -59,31 +37,9 @@ namespace Nito.AsyncEx
try
{
- await AsyncLock.LockAsync(cancellationToken);
-
- Mutex = new Mutex(initiallyOwned: true, FullName, out bool createdNew);
- if (createdNew)
- {
+ SystemDistributedLockDisposable = await SystemDistributedLock.AcquireAsync(cancellationToken: cancellationToken);
return new Key(this);
}
- else
- {
- // acquire the mutex (or timeout after 60 seconds)
- // will return false if it timed out
- var start = DateTime.Now;
- while (DateTime.Now - start < TimeSpan.FromSeconds(90))
- {
- bool acquired = Mutex.WaitOne(1);
-
- if (acquired)
- {
- return new Key(this);
- }
- await Task.Delay(pollInterval, cancellationToken);
- }
- }
- // Let it go.
- }
catch (TaskCanceledException)
{
// Let it go.
@@ -109,17 +65,8 @@ namespace Nito.AsyncEx
private void ReleaseLock()
{
- //Mutex?.ReleaseMutex();
- try
- {
- Mutex?.ReleaseMutex();
- }
- catch (ApplicationException)
- {
- }
- Mutex?.Dispose();
- Mutex = null;
- AsyncLock.ReleaseLock();
+ SystemDistributedLockDisposable?.Dispose();
+ SystemDistributedLockDisposable = null;
}
/// <summary>

MODIFY
WalletWasabi/WalletWasabi.csproj
WalletWasabi/WalletWasabi.csproj
<ItemGroup>
<PackageReference Include="NBitcoin" Version="4.1.2.12" />
- <PackageReference Include="Microsoft.Win32.Registry" Version="4.5.0" /> </ItemGroup>
+ <PackageReference Include="Microsoft.Win32.Registry" Version="4.5.0" />
+ <PackageReference Include="System.Threading.AccessControl" Version="4.5.0" /> </ItemGroup>
<ItemGroup>
<Folder Include="Gma\QrCodeNet\Encoding\ReedSolomon\" />
</ItemGroup>

Use Medalion's mutex