Skip to content

Instantly share code, notes, and snippets.

@urasandesu
Last active September 2, 2017 12:48
Show Gist options
  • Save urasandesu/449574c8581c64c5ffa0134a3bfcff73 to your computer and use it in GitHub Desktop.
Save urasandesu/449574c8581c64c5ffa0134a3bfcff73 to your computer and use it in GitHub Desktop.
Compositable Synchronization Primitives
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
namespace Synq
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("*** Wait until ending 'Process' in order of all tasks ***");
{
var waiter1 = Synchronizable.EventWait(obj => (int)obj == 1);
var waiter2 = Synchronizable.EventWait(obj => (int)obj == 2);
var waiter3 = Synchronizable.EventWait(obj => (int)obj == 3);
using (var sync = waiter1.Then(waiter2).Then(waiter3).GetSynchronizer())
{
var task1 = Task.Run(() =>
{
Console.WriteLine("Start 1 ...");
Thread.Sleep(1000);
sync.Begin(1).Wait();
Console.WriteLine("Process 1.");
sync.End(1).Wait();
Console.WriteLine("End 1.");
});
var task2 = Task.Run(() =>
{
Console.WriteLine("Start 2 ...");
Thread.Sleep(2000);
sync.Begin(2).Wait();
Console.WriteLine("Process 2.");
sync.End(2).Wait();
Console.WriteLine("End 2.");
});
var task3 = Task.Run(() =>
{
Console.WriteLine("Start 3 ...");
sync.Begin(3).Wait();
Console.WriteLine("Process 3.");
sync.End(3).Wait();
Console.WriteLine("End 3.");
});
sync.NotifyAll(false).Wait();
Console.WriteLine("Ended 'Process' in order of all tasks.");
Task.WaitAll(task1, task2, task3);
Console.WriteLine();
}
}
Console.WriteLine("*** Wait until beginning 'Start' of all tasks (the order is unrelated, also it doesn't concern whether 'Process' has processed) ***");
{
var setter1 = Synchronizable.EventSet(obj => (int)obj == 1);
var setter2 = Synchronizable.EventSet(obj => (int)obj == 2);
using (var sync = setter1.And(setter2).GetSynchronizer())
{
var task1 = Task.Run(() =>
{
Thread.Sleep(1000);
Console.WriteLine("Start 1 ...");
sync.Begin(1).Wait();
Thread.Sleep(1000);
Console.WriteLine("Process 1.");
sync.End(1).Wait();
Console.WriteLine("End 1.");
});
var task2 = Task.Run(() =>
{
Console.WriteLine("Start 2 ...");
sync.Begin(2).Wait();
Console.WriteLine("Process 2.");
sync.End(2).Wait();
Console.WriteLine("End 2.");
});
sync.NotifyAll(false).Wait();
Console.WriteLine("Begun 'Start' of all tasks.");
Task.WaitAll(task1, task2);
Console.WriteLine();
}
}
Console.WriteLine("*** Wait until beginning 'Start' of task1 or task2 and until ending 'Process' of task3 and task4 in order ***");
{
var setter1 = Synchronizable.EventSet(obj => (int)obj == 1, (sender, e) => Console.WriteLine($"Begun setter1"), (sender, e) => Console.WriteLine($"Ended setter1"), (sender, e) => Console.WriteLine($"AllNotified setter1"));
var setter2 = Synchronizable.EventSet(obj => (int)obj == 2, (sender, e) => Console.WriteLine($"Begun setter2"), (sender, e) => Console.WriteLine($"Ended setter2"), (sender, e) => Console.WriteLine($"AllNotified setter2"));
var waiter3 = Synchronizable.EventWait(obj => (int)obj == 3, (sender, e) => Console.WriteLine($"Begun waiter3"), (sender, e) => Console.WriteLine($"Ended waiter3"), (sender, e) => Console.WriteLine($"AllNotified waiter3"));
var waiter4 = Synchronizable.EventWait(obj => (int)obj == 4, (sender, e) => Console.WriteLine($"Begun waiter4"), (sender, e) => Console.WriteLine($"Ended waiter4"), (sender, e) => Console.WriteLine($"AllNotified waiter4"));
using (var sync = setter1.Or(setter2).And(waiter3.Then(waiter4)).GetSynchronizer())
{
var task1 = Task.Run(() =>
{
Thread.Sleep(3000);
Console.WriteLine("Start 1 ...");
sync.Begin(1).Wait();
Console.WriteLine("Process 1.");
sync.End(1).Wait();
Console.WriteLine("End 1.");
});
var task2 = Task.Run(() =>
{
Console.WriteLine("Start 2 ...");
sync.Begin(2).Wait();
Console.WriteLine("Process 2.");
sync.End(2).Wait();
Console.WriteLine("End 2.");
});
var task3 = Task.Run(() =>
{
Thread.Sleep(500);
Console.WriteLine("Start 3 ...");
sync.Begin(3).Wait();
Console.WriteLine("Process 3.");
sync.End(3).Wait();
Console.WriteLine("End 3.");
});
var task4 = Task.Run(() =>
{
Console.WriteLine("Start 4 ...");
sync.Begin(4).Wait();
Thread.Sleep(500);
Console.WriteLine("Process 4.");
sync.End(4).Wait();
Console.WriteLine("End 4.");
});
sync.NotifyAll(false).Wait();
Console.WriteLine("Begun 'Start' of task1 or task2 and ended 'Process' of task3 and task4 in order.");
Task.WaitAll(task1, task2, task3, task4);
Console.WriteLine();
}
}
Console.WriteLine("Press any key to continue...");
Console.ReadLine();
// Result ---
//
// *** Wait until ending 'Process' in order of all tasks ***
// Start 1 ...
// Start 2 ...
// Start 3 ...
// Process 1.
// End 1.
// Process 2.
// End 2.
// Process 3.
// End 3.
// Ended 'Process' in order of all tasks.
//
// *** Wait until beginning 'Start' of all tasks (the order is unrelated, also it doesn't concern whether 'Process' has processed) ***
// Start 2 ...
// Process 2.
// End 2.
// Start 1 ...
// Begun 'Start' of all tasks.
// Process 1.
// End 1.
//
// *** Wait until beginning 'Start' of task1 or task2 and until ending 'Process' of task3 and task4 in order ***
// Start 2 ...
// Start 4 ...
// AllNotified setter1
// AllNotified setter2
// Start 3 ...
// AllNotified waiter3
// Process 3.
// Begun waiter4
// Ended setter1
// Ended setter2
// Ended waiter4
// End 3.
// Process 2.
// Ended setter1
// Ended setter2
// End 2.
// Begun setter2
// Start 1 ...
// Process 4.
// Ended setter1
// Ended setter2
// Begun setter1
// Ended waiter3
// AllNotified waiter4
// Begun 'Start' of task1 or task2 and ended 'Process' of task3 and task4 in order.
// End 4.
// Process 1.
// Ended setter1
// Ended setter2
// End 1.
//
// Press any key to continue...
}
}
public interface ISynchronizable
{
ISynchronizer GetSynchronizer();
}
[Serializable]
class InternalSynchronousOptions
{
public InternalSynchronousOptions WithHandlingCondition(bool ignores = true)
{
IgnoresHandlingCondition = ignores;
return this;
}
public bool IgnoresHandlingCondition { get; private set; }
}
[Serializable]
public class SynchronousOptions
{
internal static SynchronousOptions UpdateInternalOptions(SynchronousOptions opts, InternalSynchronousOptions internalOpts)
{
if (opts == null)
opts = new SynchronousOptions();
opts.InternalOptions = internalOpts;
return opts;
}
internal InternalSynchronousOptions InternalOptions { get; private set; }
}
[Serializable]
public class HandledEventArgs : EventArgs
{
public new static readonly HandledEventArgs Empty = new HandledEventArgs();
public HandledEventArgs() :
this(null, null)
{ }
public HandledEventArgs(object obj, SynchronousOptions opts = null)
{
Object = obj;
Options = opts;
}
public object Object { get; private set; }
public SynchronousOptions Options { get; private set; }
}
[Serializable]
public class AllNotifiedEventArgs : EventArgs
{
public new static readonly AllNotifiedEventArgs Empty = new AllNotifiedEventArgs();
public AllNotifiedEventArgs() :
this(false)
{ }
public AllNotifiedEventArgs(bool state)
{
State = state;
}
public bool State { get; private set; }
}
public interface ISynchronizer : IDisposable
{
bool WillHandle(object obj);
Task Begin(object obj, SynchronousOptions opts = null);
event EventHandler<HandledEventArgs> Begun;
Task End(object obj, SynchronousOptions opts = null);
event EventHandler<HandledEventArgs> Ended;
Task NotifyAll(bool state);
event EventHandler<AllNotifiedEventArgs> AllNotified;
}
abstract class EventSynchronizable : ISynchronizable
{
readonly Predicate<object> m_willHandle;
readonly EventHandler<HandledEventArgs> m_handleBegun;
readonly EventHandler<HandledEventArgs> m_handleEnded;
readonly EventHandler<AllNotifiedEventArgs> m_handleAllNotified;
public EventSynchronizable(Predicate<object> willHandle,
EventHandler<HandledEventArgs> handleBegun = null, EventHandler<HandledEventArgs> handleEnded = null, EventHandler<AllNotifiedEventArgs> handleAllNotified = null)
{
Debug.Assert(willHandle != null, $"Value cannot be null. Parameter name: { nameof(willHandle) }");
m_willHandle = willHandle;
m_handleBegun = handleBegun;
m_handleEnded = handleEnded;
m_handleAllNotified = handleAllNotified;
}
public ISynchronizer GetSynchronizer()
{
return GetEventSynchronizer(m_willHandle, m_handleBegun, m_handleEnded, m_handleAllNotified);
}
protected abstract EventSynchronizer GetEventSynchronizer(Predicate<object> willHandle,
EventHandler<HandledEventArgs> handleBegun = null, EventHandler<HandledEventArgs> handleEnded = null, EventHandler<AllNotifiedEventArgs> handleAllNotified = null);
}
class EventWaitable : EventSynchronizable
{
public EventWaitable(Predicate<object> willHandle,
EventHandler<HandledEventArgs> handleBegun = null, EventHandler<HandledEventArgs> handleEnded = null, EventHandler<AllNotifiedEventArgs> handleAllNotified = null) :
base(willHandle, handleBegun, handleEnded, handleAllNotified)
{ }
protected override EventSynchronizer GetEventSynchronizer(Predicate<object> willHandle,
EventHandler<HandledEventArgs> handleBegun = null, EventHandler<HandledEventArgs> handleEnded = null, EventHandler<AllNotifiedEventArgs> handleAllNotified = null)
{
return new EventWaiter(willHandle, handleBegun, handleEnded, handleAllNotified);
}
}
class EventSettable : EventSynchronizable
{
public EventSettable(Predicate<object> willHandle,
EventHandler<HandledEventArgs> handleBegun = null, EventHandler<HandledEventArgs> handleEnded = null, EventHandler<AllNotifiedEventArgs> handleAllNotified = null) :
base(willHandle, handleBegun, handleEnded, handleAllNotified)
{ }
protected override EventSynchronizer GetEventSynchronizer(Predicate<object> willHandle,
EventHandler<HandledEventArgs> handleBegun = null, EventHandler<HandledEventArgs> handleEnded = null, EventHandler<AllNotifiedEventArgs> handleAllNotified = null)
{
return new EventSetter(willHandle, handleBegun, handleEnded, handleAllNotified);
}
}
abstract class EventSynchronizer : ISynchronizer
{
readonly Predicate<object> m_willHandle;
readonly EventHandler<HandledEventArgs> m_handleBegun;
readonly EventHandler<HandledEventArgs> m_handleEnded;
readonly EventHandler<AllNotifiedEventArgs> m_handleAllNotified;
protected EventSynchronizer(Predicate<object> willHandle,
EventHandler<HandledEventArgs> handleBegun = null, EventHandler<HandledEventArgs> handleEnded = null, EventHandler<AllNotifiedEventArgs> handleAllNotified = null)
{
Debug.Assert(willHandle != null, $"Value cannot be null. Parameter name: { nameof(willHandle) }");
m_willHandle = willHandle;
m_handleBegun = handleBegun;
if (m_handleBegun != null)
Begun += m_handleBegun;
m_handleEnded = handleEnded;
if (m_handleEnded != null)
Ended += m_handleEnded;
m_handleAllNotified = handleAllNotified;
if (m_handleAllNotified != null)
AllNotified += m_handleAllNotified;
}
protected ManualResetEventSlim WaitHandle { get; private set; } = new ManualResetEventSlim(false);
public bool WillHandle(object obj)
{
return m_willHandle(obj);
}
public abstract Task Begin(object obj, SynchronousOptions opts = null);
public event EventHandler<HandledEventArgs> Begun;
protected virtual void OnBegun(HandledEventArgs e)
{
Begun?.Invoke(this, e);
}
public abstract Task End(object obj, SynchronousOptions opts = null);
public event EventHandler<HandledEventArgs> Ended;
protected virtual void OnEnded(HandledEventArgs e)
{
Ended?.Invoke(this, e);
}
public Task NotifyAll(bool state)
{
if (state)
{
return Task.Run(() =>
{
AllNotified?.Invoke(this, new AllNotifiedEventArgs(state));
WaitHandle.Set();
});
}
else
{
return Task.Run(() =>
{
AllNotified?.Invoke(this, new AllNotifiedEventArgs(state));
WaitHandle.Wait();
});
}
}
public event EventHandler<AllNotifiedEventArgs> AllNotified;
bool m_disposed;
protected virtual void Dispose(bool disposing)
{
if (!m_disposed)
{
if (disposing)
{
WaitHandle.Dispose();
if (m_handleBegun != null)
Begun -= m_handleBegun;
if (m_handleEnded != null)
Ended -= m_handleEnded;
if (m_handleAllNotified != null)
AllNotified -= m_handleAllNotified;
}
m_disposed = true;
}
}
public void Dispose()
{
Dispose(true);
}
}
class EventWaiter : EventSynchronizer
{
public EventWaiter(Predicate<object> willHandle,
EventHandler<HandledEventArgs> handleBegun = null, EventHandler<HandledEventArgs> handleEnded = null, EventHandler<AllNotifiedEventArgs> handleAllNotified = null) :
base(willHandle, handleBegun, handleEnded, handleAllNotified)
{ }
public override Task Begin(object obj, SynchronousOptions opts = null)
{
return Task.Run(() =>
{
if (opts?.InternalOptions?.IgnoresHandlingCondition == true || WillHandle(obj))
{
OnBegun(new HandledEventArgs(obj, opts));
WaitHandle.Wait();
}
});
}
public override Task End(object obj, SynchronousOptions opts = null)
{
return Task.Run(() =>
{
if (opts?.InternalOptions?.IgnoresHandlingCondition == true || WillHandle(obj))
{
OnEnded(new HandledEventArgs(obj, opts));
WaitHandle.Set();
}
});
}
}
class EventSetter : EventSynchronizer
{
public EventSetter(Predicate<object> willHandle,
EventHandler<HandledEventArgs> handleBegun = null, EventHandler<HandledEventArgs> handleEnded = null, EventHandler<AllNotifiedEventArgs> handleAllNotified = null) :
base(willHandle, handleBegun, handleEnded, handleAllNotified)
{ }
public override Task Begin(object obj, SynchronousOptions opts = null)
{
return Task.Run(() =>
{
if (opts?.InternalOptions?.IgnoresHandlingCondition == true || WillHandle(obj))
{
OnBegun(new HandledEventArgs(obj, opts));
WaitHandle.Set();
}
});
}
public override Task End(object obj, SynchronousOptions opts = null)
{
OnEnded(new HandledEventArgs(obj, opts));
return Task.CompletedTask;
}
}
abstract class BinarySynchronizable : ISynchronizable
{
readonly ISynchronizable m_lhs;
readonly ISynchronizable m_rhs;
protected BinarySynchronizable(ISynchronizable lhs, ISynchronizable rhs)
{
Debug.Assert(lhs != null, $"Value cannot be null. Parameter name: { nameof(lhs) }");
Debug.Assert(rhs != null, $"Value cannot be null. Parameter name: { nameof(rhs) }");
m_lhs = lhs;
m_rhs = rhs;
}
public ISynchronizer GetSynchronizer()
{
return GetBinarySynchronizer(m_lhs.GetSynchronizer(), m_rhs.GetSynchronizer());
}
protected abstract BinarySynchronizer GetBinarySynchronizer(ISynchronizer lhs, ISynchronizer rhs);
}
class ThenSynchronizable : BinarySynchronizable
{
public ThenSynchronizable(ISynchronizable lhs, ISynchronizable rhs) :
base(lhs, rhs)
{ }
protected override BinarySynchronizer GetBinarySynchronizer(ISynchronizer lhs, ISynchronizer rhs)
{
return new ThenSynchronizer(lhs, rhs);
}
}
class AndSynchronizable : BinarySynchronizable
{
public AndSynchronizable(ISynchronizable lhs, ISynchronizable rhs) :
base(lhs, rhs)
{ }
protected override BinarySynchronizer GetBinarySynchronizer(ISynchronizer lhs, ISynchronizer rhs)
{
return new AndSynchronizer(lhs, rhs);
}
}
class OrSynchronizable : BinarySynchronizable
{
public OrSynchronizable(ISynchronizable lhs, ISynchronizable rhs) :
base(lhs, rhs)
{ }
protected override BinarySynchronizer GetBinarySynchronizer(ISynchronizer lhs, ISynchronizer rhs)
{
return new OrSynchronizer(lhs, rhs);
}
}
abstract class BinarySynchronizer : ISynchronizer
{
protected BinarySynchronizer(ISynchronizer lhs, ISynchronizer rhs)
{
Debug.Assert(lhs != null, $"Value cannot be null. Parameter name: { nameof(lhs) }");
Debug.Assert(rhs != null, $"Value cannot be null. Parameter name: { nameof(rhs) }");
LeftSynchronizer = lhs;
RightSynchronizer = rhs;
}
protected ISynchronizer LeftSynchronizer { get; private set; }
protected ISynchronizer RightSynchronizer { get; private set; }
public abstract bool WillHandle(object obj);
public abstract Task Begin(object obj, SynchronousOptions opts = null);
public event EventHandler<HandledEventArgs> Begun
{
add
{
if (LeftSynchronizer != null)
LeftSynchronizer.Begun += value;
if (RightSynchronizer != null)
RightSynchronizer.Begun += value;
}
remove
{
if (LeftSynchronizer != null)
LeftSynchronizer.Begun -= value;
if (RightSynchronizer != null)
RightSynchronizer.Begun -= value;
}
}
public abstract Task End(object obj, SynchronousOptions opts = null);
public event EventHandler<HandledEventArgs> Ended
{
add
{
if (LeftSynchronizer != null)
LeftSynchronizer.Ended += value;
if (RightSynchronizer != null)
RightSynchronizer.Ended += value;
}
remove
{
if (LeftSynchronizer != null)
LeftSynchronizer.Ended -= value;
if (RightSynchronizer != null)
RightSynchronizer.Ended -= value;
}
}
public abstract Task NotifyAll(bool state);
public event EventHandler<AllNotifiedEventArgs> AllNotified
{
add
{
if (LeftSynchronizer != null)
LeftSynchronizer.AllNotified += value;
if (RightSynchronizer != null)
RightSynchronizer.AllNotified += value;
}
remove
{
if (LeftSynchronizer != null)
LeftSynchronizer.AllNotified -= value;
if (RightSynchronizer != null)
RightSynchronizer.AllNotified -= value;
}
}
bool m_disposed;
protected virtual void Dispose(bool disposing)
{
if (!m_disposed)
{
if (disposing)
{
LeftSynchronizer.Dispose();
RightSynchronizer.Dispose();
}
m_disposed = true;
}
}
public void Dispose()
{
Dispose(true);
}
}
class ThenSynchronizer : BinarySynchronizer
{
public ThenSynchronizer(ISynchronizer lhs, ISynchronizer rhs) :
base(lhs, rhs)
{ }
public override bool WillHandle(object obj)
{
return RightSynchronizer.WillHandle(obj);
}
public override async Task Begin(object obj, SynchronousOptions opts = null)
{
if (LeftSynchronizer.WillHandle(obj) && LeftSynchronizer is BinarySynchronizer)
await LeftSynchronizer.Begin(obj, opts);
else
await RightSynchronizer.Begin(obj, opts);
}
public override async Task End(object obj, SynchronousOptions opts = null)
{
var willLeftHandle = LeftSynchronizer.WillHandle(obj);
var willRightHandle = RightSynchronizer.WillHandle(obj);
if (willLeftHandle && willRightHandle)
await Task.CompletedTask;
else if (willLeftHandle)
await RightSynchronizer.End(obj, SynchronousOptions.UpdateInternalOptions(opts, new InternalSynchronousOptions().WithHandlingCondition()));
else if (willRightHandle)
await LeftSynchronizer.End(obj, SynchronousOptions.UpdateInternalOptions(opts, new InternalSynchronousOptions().WithHandlingCondition()));
else
await LeftSynchronizer.End(obj, opts);
}
public override async Task NotifyAll(bool state)
{
await LeftSynchronizer.NotifyAll(state);
await RightSynchronizer.NotifyAll(state);
}
}
class AndSynchronizer : BinarySynchronizer
{
public AndSynchronizer(ISynchronizer lhs, ISynchronizer rhs) :
base(lhs, rhs)
{ }
public override bool WillHandle(object obj)
{
return true;
}
public override Task Begin(object obj, SynchronousOptions opts = null)
{
return Task.WhenAll(LeftSynchronizer.Begin(obj, opts), RightSynchronizer.Begin(obj, opts));
}
public override Task End(object obj, SynchronousOptions opts = null)
{
return Task.WhenAll(LeftSynchronizer.End(obj, opts), RightSynchronizer.End(obj, opts));
}
public override Task NotifyAll(bool state)
{
return Task.WhenAll(LeftSynchronizer.NotifyAll(state), RightSynchronizer.NotifyAll(state));
}
}
class OrSynchronizer : BinarySynchronizer
{
public OrSynchronizer(ISynchronizer lhs, ISynchronizer rhs) :
base(lhs, rhs)
{ }
public override bool WillHandle(object obj)
{
return true;
}
public override Task Begin(object obj, SynchronousOptions opts = null)
{
return Task.WhenAny(LeftSynchronizer.Begin(obj, opts), RightSynchronizer.Begin(obj, opts));
}
public override Task End(object obj, SynchronousOptions opts = null)
{
return Task.WhenAny(LeftSynchronizer.End(obj, opts), RightSynchronizer.End(obj, opts));
}
public override Task NotifyAll(bool state)
{
return Task.WhenAny(LeftSynchronizer.NotifyAll(state), RightSynchronizer.NotifyAll(state));
}
}
public static class Synchronizable
{
public static ISynchronizable EventWait(Predicate<object> willHandle,
EventHandler<HandledEventArgs> handleBegun = null, EventHandler<HandledEventArgs> handleEnded = null, EventHandler<AllNotifiedEventArgs> handleAllNotified = null)
{
if (willHandle == null)
throw new ArgumentNullException(nameof(willHandle));
return new EventWaitable(willHandle, handleBegun, handleEnded, handleAllNotified);
}
public static ISynchronizable EventSet(Predicate<object> willHandle,
EventHandler<HandledEventArgs> handleBegun = null, EventHandler<HandledEventArgs> handleEnded = null, EventHandler<AllNotifiedEventArgs> handleAllNotified = null)
{
if (willHandle == null)
throw new ArgumentNullException(nameof(willHandle));
return new EventSettable(willHandle, handleBegun, handleEnded, handleAllNotified);
}
public static ISynchronizable Then(this ISynchronizable lhs, ISynchronizable rhs)
{
if (lhs == null)
throw new ArgumentNullException(nameof(lhs));
if (rhs == null)
throw new ArgumentNullException(nameof(rhs));
return new ThenSynchronizable(lhs, rhs);
}
public static ISynchronizable And(this ISynchronizable lhs, ISynchronizable rhs)
{
if (lhs == null)
throw new ArgumentNullException(nameof(lhs));
if (rhs == null)
throw new ArgumentNullException(nameof(rhs));
return new AndSynchronizable(lhs, rhs);
}
public static ISynchronizable Or(this ISynchronizable lhs, ISynchronizable rhs)
{
if (lhs == null)
throw new ArgumentNullException(nameof(lhs));
if (rhs == null)
throw new ArgumentNullException(nameof(rhs));
return new OrSynchronizable(lhs, rhs);
}
}
}
@kekyo
Copy link

kekyo commented Aug 29, 2017

面白そうだったので書いてみました。Awaiter実装しようとしたんですが、Taskのほうが汎用性ありそうだったのでそっちで。
https://gist.github.com/kekyo/3f1e58766bd6f1273d725509f54f0fd3

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment