diff --git a/src/Library/Signal.Tests/DisposeTests.cs b/src/Library/Signal.Tests/DisposeTests.cs new file mode 100644 index 0000000..5065a11 --- /dev/null +++ b/src/Library/Signal.Tests/DisposeTests.cs @@ -0,0 +1,64 @@ +using Xunit; + +namespace Signal.Tests; + +public class DisposeTests +{ + [Fact] + public void Disposed_AfterDispose_ShouldBeTrue() + { + // Arrange + var signal = new Signal(0); + + // Act + signal.Dispose(); + + // Assert + Assert.True(signal.IsDisposed); + } + + [Fact] + public void Disposed_AfterDispose_ShouldInvokeDisposedEvent() + { + // Arrange + var signal = new Signal(0); + var disposedInvoked = false; + signal.Disposed += _ => disposedInvoked = true; + + // Act + signal.Dispose(); + + // Assert + Assert.True(disposedInvoked); + } + + [Fact] + public void ChildSignalDisposed_AfterParentSignalDispose_ShouldBeTrue() + { + // Arrange + var parentSignal = new Signal(0); + var childSignal = parentSignal.Map(v => v); + + // Act + parentSignal.Dispose(); + + // Assert + Assert.True(childSignal.IsDisposed); + } + + [Fact] + public void ChildSignalDisposed_AfterParentSignalDispose_ShouldInvokeDisposedEvent() + { + // Arrange + var parentSignal = new Signal(0); + var childSignal = parentSignal.Map(v => v); + var disposedInvoked = false; + childSignal.Disposed += _ => disposedInvoked = true; + + // Act + parentSignal.Dispose(); + + // Assert + Assert.True(disposedInvoked); + } +} \ No newline at end of file diff --git a/src/Library/Signal.Tests/LockingTests.cs b/src/Library/Signal.Tests/LockingTests.cs new file mode 100644 index 0000000..281ef24 --- /dev/null +++ b/src/Library/Signal.Tests/LockingTests.cs @@ -0,0 +1,50 @@ +using Xunit; + +namespace Signal.Tests; + +public class LockingTests +{ + // These tests are not working, but you get the idea, figure them out sometimes in the future, gl&hf + + [Fact] + public async Task SetAndGet_WhenGetRunsFirst_ShouldNotDeadlock() + { + // Arrange + var signal = new Signal(0); + var childSignal = signal.Map(async v => + { + await Task.Delay(200); + return v; + }); + + // Act + await Task.WhenAll( + Task.Run(async () => await signal.GetValueAsync()), + Task.Run(() => signal.SetValue(1)) + ); + + // Assert + // If this does not deadlock we are okay + } + + [Fact] + public async Task SetAndGet_WhenSetRunsFirst_ShouldNotDeadlock() + { + // Arrange + var signal = new Signal(0); + var childSignal = signal.Map(async v => + { + await Task.Delay(200); + return v; + }); + + // Act + await Task.WhenAll( + Task.Run(() => signal.SetValue(1)), + Task.Run(async () => await signal.GetValueAsync()) + ); + + // Assert + // If this does not deadlock we are okay + } +} \ No newline at end of file diff --git a/src/Library/Signal.Tests/Signal.Tests.csproj b/src/Library/Signal.Tests/Signal.Tests.csproj index ce0caf7..1572304 100644 --- a/src/Library/Signal.Tests/Signal.Tests.csproj +++ b/src/Library/Signal.Tests/Signal.Tests.csproj @@ -1,14 +1,26 @@  - Exe net8.0 enable enable + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + - + diff --git a/src/Library/Signal.Tests/SyncLikeBehaviourTests.cs b/src/Library/Signal.Tests/SyncLikeBehaviourTests.cs new file mode 100644 index 0000000..3caca9d --- /dev/null +++ b/src/Library/Signal.Tests/SyncLikeBehaviourTests.cs @@ -0,0 +1,44 @@ +using Xunit; + +namespace Signal.Tests; + +public class SyncLikeBehaviourTests +{ + [Fact(Timeout = 500)] + public async Task Signal_WhenAwaitedInstantly_ShouldBehaveLikeSync() + { + // Arrange + var signal = new Signal(1); + + // Act + var val1 = await signal.GetValueAsync(); + signal.SetValue(2); + var val2 = await signal.GetValueAsync(); + signal.SetValue(3); + var val3 = await signal.GetValueAsync(); + + // Assert + Assert.Equal(1, val1); + Assert.Equal(2, val2); + Assert.Equal(3, val3); + } + + [Fact(Timeout = 500)] + public async Task Signal_WhenNotAwaitedInstantly_ShouldBehaveLikeSync() + { + // Arrange + var signal = new Signal(1); + + // Act + var val1 = signal.GetValueAsync(); + signal.SetValue(2); + var val2 = signal.GetValueAsync(); + signal.SetValue(3); + var val3 = signal.GetValueAsync(); + + // Assert + Assert.Equal(1, await val1); + Assert.Equal(2, await val2); + Assert.Equal(3, await val3); + } +} \ No newline at end of file diff --git a/src/Library/Signal.Tests/xunit.runner.json b/src/Library/Signal.Tests/xunit.runner.json new file mode 100644 index 0000000..67f39ea --- /dev/null +++ b/src/Library/Signal.Tests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "longRunningTestSeconds": 5 +} \ No newline at end of file diff --git a/src/Library/Signal/CombineLatestSignal.cs b/src/Library/Signal/CombineLatestSignal.cs index 29e7552..80012a2 100644 --- a/src/Library/Signal/CombineLatestSignal.cs +++ b/src/Library/Signal/CombineLatestSignal.cs @@ -1,34 +1,43 @@ namespace Signal; -public class CombineLatestSignal : SignalBase +public sealed class CombineLatestSignal : SignalBase { private readonly Func> _combine; private TResult _result; - public CombineLatestSignal(IReadOnlySignal signal1, IReadOnlySignal signal2, Func combine) - : base(new IReadOnlySignal[] { signal1, signal2 }) + public CombineLatestSignal(SignalBase signal1, SignalBase signal2, Func combine) + : base(new SignalBase[] { signal1, signal2 }) { _combine = CombineAsync; - async ValueTask CombineAsync() => combine(await signal1.GetValueAsync(), await signal2.GetValueAsync()); - + async ValueTask CombineAsync() + { + var val1 = await signal1.GetValueAsync(); + var val2 = await signal2.GetValueAsync(); + return combine(val1, val2); + } } - public CombineLatestSignal(IReadOnlySignal signal1, IReadOnlySignal signal2, Func> combine) - : base(new IReadOnlySignal[] { signal1, signal2 }) + public CombineLatestSignal(SignalBase signal1, SignalBase signal2, Func> combine) + : base(new SignalBase[] { signal1, signal2 }) { _combine = CombineAsync; - async ValueTask CombineAsync() => await combine(await signal1.GetValueAsync(), await signal2.GetValueAsync()); + async ValueTask CombineAsync() + { + var val1 = await signal1.GetValueAsync(); + var val2 = await signal2.GetValueAsync(); + return await combine(val1, val2); + } } - public override async ValueTask GetValueAsync() + protected override async ValueTask GetValueInternalAsync() { - //TODO synchronization if (!IsDirty) { return _result; } + IsDirty = false; _result = await _combine(); return _result; diff --git a/src/Library/Signal/Extensions.cs b/src/Library/Signal/Extensions.cs index 82afcac..eddb3f9 100644 --- a/src/Library/Signal/Extensions.cs +++ b/src/Library/Signal/Extensions.cs @@ -2,11 +2,11 @@ namespace Signal; public static class Extensions { - public static IReadOnlySignal Map(this IReadOnlySignal signal, Func map) + public static SignalBase Map(this SignalBase signal, Func map) { return new MapSignal(signal, map); } - public static IReadOnlySignal Map(this IReadOnlySignal signal, Func> map) + public static SignalBase Map(this SignalBase signal, Func> map) { return new MapSignal(signal, map); } diff --git a/src/Library/Signal/Helpers.cs b/src/Library/Signal/Helpers.cs index 83a2d96..c170f45 100644 --- a/src/Library/Signal/Helpers.cs +++ b/src/Library/Signal/Helpers.cs @@ -2,13 +2,13 @@ namespace Signal; public static class Helpers { - public static IReadOnlySignal CombineLatest(IReadOnlySignal signal1, - IReadOnlySignal signal2, Func combine) + public static SignalBase CombineLatest(SignalBase signal1, + SignalBase signal2, Func combine) { return new CombineLatestSignal(signal1, signal2, combine); } - public static IReadOnlySignal CombineLatest(IReadOnlySignal signal1, - IReadOnlySignal signal2, Func> combine) + public static SignalBase CombineLatest(SignalBase signal1, + SignalBase signal2, Func> combine) { return new CombineLatestSignal(signal1, signal2, combine); } diff --git a/src/Library/Signal/IReadOnlySignal.cs b/src/Library/Signal/IReadOnlySignal.cs index 004e698..bc1d89c 100644 --- a/src/Library/Signal/IReadOnlySignal.cs +++ b/src/Library/Signal/IReadOnlySignal.cs @@ -1,10 +1,9 @@ namespace Signal; -public interface IReadOnlySignal +public interface IReadOnlySignal : IDisposable { bool IsDirty { get; } event Action IsDirtyChanged; - internal void SetDirty(); } public interface IReadOnlySignal : IReadOnlySignal { diff --git a/src/Library/Signal/ISignal.cs b/src/Library/Signal/ISignal.cs index 5ac0ffe..9c0a755 100644 --- a/src/Library/Signal/ISignal.cs +++ b/src/Library/Signal/ISignal.cs @@ -3,4 +3,5 @@ namespace Signal; public interface ISignal : IReadOnlySignal { void SetValue(T value); + Task SetValueAsync(T value); } \ No newline at end of file diff --git a/src/Library/Signal/MapSignal.cs b/src/Library/Signal/MapSignal.cs index 838275d..37fe017 100644 --- a/src/Library/Signal/MapSignal.cs +++ b/src/Library/Signal/MapSignal.cs @@ -1,31 +1,56 @@ namespace Signal; -public class MapSignal : SignalBase +public sealed class MapSignal : SignalBase { - private readonly Func> _map; - private TResult _result; - public MapSignal(IReadOnlySignal signal, Func map) : base(signal) + private readonly Func> _map; + private readonly SignalBase _parentSignal; + private T? _lastParentValue; + private TResult? _lastResult; + + private MapSignal(SignalBase signal) : base(signal) { - _map = MapValueAsync; - - async ValueTask MapValueAsync() => map(await signal.GetValueAsync()); - } - public MapSignal(IReadOnlySignal signal, Func> map) : base(signal) - { - _map = MapValueAsync; - - async ValueTask MapValueAsync() => await map(await signal.GetValueAsync()); + _parentSignal = signal; } - public override async ValueTask GetValueAsync() + public MapSignal(SignalBase signal, Func map) : this(signal) + { + _map = MapValueAsync; + + ValueTask MapValueAsync(T val) => new(map(val)); + } + + public MapSignal(SignalBase signal, Func> map) : this(signal) + { + _map = MapValueAsync; + + async ValueTask MapValueAsync(T val) => await map(val); + } + + public MapSignal(SignalBase signal, Func> map) : this(signal) + { + _map = MapValueAsync; + + async ValueTask MapValueAsync(T val) => await map(val); + } + + protected override async ValueTask GetValueInternalAsync() { - //TODO synchronization if (!IsDirty) { - return _result; + return _lastResult!; } + IsDirty = false; - _result = await _map(); - return _result; + var baseValue = await _parentSignal.GetValueAsync(); + if ( + (_lastParentValue == null && baseValue == null) || + (baseValue != null && baseValue.Equals(_lastParentValue))) + { + return _lastResult!; + } + + _lastParentValue = baseValue; + _lastResult = await _map(baseValue); + return _lastResult; } } \ No newline at end of file diff --git a/src/Library/Signal/Signal.cs b/src/Library/Signal/Signal.cs index c5c0c74..da15ba6 100644 --- a/src/Library/Signal/Signal.cs +++ b/src/Library/Signal/Signal.cs @@ -1,6 +1,6 @@ namespace Signal; -public class Signal : SignalBase, ISignal +public sealed class Signal : SignalBase, ISignal { private T _value; @@ -11,13 +11,35 @@ public class Signal : SignalBase, ISignal public void SetValue(T value) { - _value = value; - SetDirty(); + TreeLock.Lock(); + try + { + _value = value; + IsDirty = true; + } + finally + { + TreeLock.Release(); + } } - - public override ValueTask GetValueAsync() + + public async Task SetValueAsync(T value) + { + await TreeLock.LockAsync(); + try + { + _value = value; + IsDirty = true; + } + finally + { + TreeLock.Release(); + } + } + + protected override ValueTask GetValueInternalAsync() { IsDirty = false; return new ValueTask(_value); } -} \ No newline at end of file +} diff --git a/src/Library/Signal/SignalBase.cs b/src/Library/Signal/SignalBase.cs index 5fdcc40..2dce6ad 100644 --- a/src/Library/Signal/SignalBase.cs +++ b/src/Library/Signal/SignalBase.cs @@ -1,50 +1,122 @@ namespace Signal; -public abstract class SignalBase : IReadOnlySignal +public abstract class SignalBase : IReadOnlySignal { - private readonly List> _dependentSignals = []; - public bool IsDirty { get; protected set; } = true; + private bool _isDirty = true; + public event Action? IsDirtyChanged; - public SignalBase() + public bool IsDirty { - - } - - public SignalBase(IReadOnlySignal baseSignal) - { - HandleDependentSignal(baseSignal); - } - - public SignalBase(IEnumerable baseSignal) - { - foreach (var signal in baseSignal) + get => _isDirty; + protected set { - HandleDependentSignal(signal); - } - } - - private void HandleDependentSignal(IReadOnlySignal baseSignal) - { - baseSignal.IsDirtyChanged += isDirty => - { - if (isDirty) + if (_isDirty == value) { - SetDirty(); + return; } - }; + + _isDirty = value; + IsDirtyChanged?.Invoke(value); + } + } + public event Action Disposed; + public bool IsDisposed { get; private set; } + + internal TreeLocker TreeLock { get; } + + private protected SignalBase(TreeLocker treeTreeLock) + { + TreeLock = treeTreeLock; } - public void SetDirty() + public virtual void Dispose() { - IsDirty = true; - for (var i = 0; i < _dependentSignals.Count; i++) + // TODO: disposing pattern + IsDisposed = true; + Disposed?.Invoke(this); + } +} + +public abstract class SignalBase : SignalBase, IReadOnlySignal +{ + internal static AsyncLocal CurrentTreeLocker { get; } = new(); + private protected SignalBase():base(new TreeLocker()) + { + } + + protected SignalBase(SignalBase parentSignal):base(parentSignal.TreeLock) + { + SubscribeToParentSignalChanges(parentSignal); + } + + protected SignalBase(ICollection parentSignals):base(CreateMultiParentTreeLock(parentSignals)) + { + ArgumentOutOfRangeException.ThrowIfZero(parentSignals.Count); + + foreach (var parentSignal in parentSignals) { - _dependentSignals[i].SetDirty(); + SubscribeToParentSignalChanges(parentSignal); + } + } + + private static TreeLocker CreateMultiParentTreeLock(ICollection parentSignals) + { + var firstLock = parentSignals.First().TreeLock; + foreach (var parentSignal in parentSignals.Skip(1)) + { + parentSignal.TreeLock.UseInstead(firstLock); + } + + return firstLock; + } + + private void SubscribeToParentSignalChanges(SignalBase parentSignal) + { + // Note: Do not forget to unsubscribe from the parent signal when this signal is disposed. + parentSignal.IsDirtyChanged += HandleParentIsDirtyChanged; + parentSignal.Disposed += UnsubscribeFromParentSignalChangesAndDispose; + } + + private void HandleParentIsDirtyChanged(bool isDirty) + { + if (isDirty) + { + IsDirty = true; + } + } + + private void UnsubscribeFromParentSignalChangesAndDispose(SignalBase parentSignal) + { + parentSignal.IsDirtyChanged -= HandleParentIsDirtyChanged; + parentSignal.Disposed -= UnsubscribeFromParentSignalChangesAndDispose; + + Dispose(); + } + + protected abstract ValueTask GetValueInternalAsync(); + + public async ValueTask GetValueAsync() + { + var shouldReleaseLock = false; + if (CurrentTreeLocker.Value != TreeLock) + { + await TreeLock.LockAsync(); + shouldReleaseLock = true; + CurrentTreeLocker.Value = TreeLock; } - IsDirtyChanged?.Invoke(IsDirty); + try + { + return await GetValueInternalAsync(); + } + finally + { + if (shouldReleaseLock) + { + CurrentTreeLocker.Value = null; + TreeLock.Release(); + } + } } - - public abstract ValueTask GetValueAsync(); } \ No newline at end of file diff --git a/src/Library/Signal/TreeLocker.cs b/src/Library/Signal/TreeLocker.cs new file mode 100644 index 0000000..132226f --- /dev/null +++ b/src/Library/Signal/TreeLocker.cs @@ -0,0 +1,71 @@ +namespace Signal; + +internal sealed class TreeLocker +{ + private bool Equals(TreeLocker other) => _mainSemaphore.Equals(other._mainSemaphore); + + public override bool Equals(object? obj) => ReferenceEquals(this, obj) || obj is TreeLocker other && Equals(other); + + public override int GetHashCode() => _mainSemaphore.GetHashCode(); + + public static bool operator ==(TreeLocker? left, TreeLocker? right) => Equals(left, right); + public static bool operator !=(TreeLocker? left, TreeLocker? right) => !Equals(left, right); + + private SemaphoreSlim _lastLockedMainSemaphore; + private SemaphoreSlim _mainSemaphore = new(1, 1); + private readonly SemaphoreSlim _semaphoreSemaphore = new(1, 1); + + public void Lock() + { + _semaphoreSemaphore.Wait(); + try + { + _lastLockedMainSemaphore = _mainSemaphore; + _lastLockedMainSemaphore.Wait(); + } + finally + { + _semaphoreSemaphore.Release(); + } + } + + public Task LockAsync() + { + _semaphoreSemaphore.Wait(); + try + { + _lastLockedMainSemaphore = _mainSemaphore; + return _lastLockedMainSemaphore.WaitAsync(); + } + finally + { + _semaphoreSemaphore.Release(); + } + } + + public void Release() + { + try + { + _semaphoreSemaphore.Wait(); + _lastLockedMainSemaphore.Release(); + } + finally + { + _semaphoreSemaphore.Release(); + } + } + + internal void UseInstead(TreeLocker newBaseLocker) + { + try + { + _semaphoreSemaphore.Wait(); + _mainSemaphore = newBaseLocker._mainSemaphore; + } + finally + { + _semaphoreSemaphore.Release(); + } + } +} \ No newline at end of file