diff --git a/src/ClockQuantization.xml b/src/ClockQuantization.xml index aca3ac3..4f982e7 100644 --- a/src/ClockQuantization.xml +++ b/src/ClockQuantization.xml @@ -159,10 +159,28 @@ The maximum of each If also implements , the will pick up on external - events. Also, if is , + events. Also, if is non-, the will pick up on external events, instead of relying on an internal metronome. + + + Puts the into a quiescent state, effectively freeing any owned unmanaged resources. While in a quiescent state, the will not raise any events, nor perform metronomic advance operations. + + + Any externally initiated advance operation will automatically take the back into normal operation. + + + + + Takes the out of a quiescent state into normal operation. + + + + + Returns if the is in a quiescent state, otherwise. + + @@ -280,7 +298,7 @@ - Converts to a in UTC. + Converts clock-specific to a in UTC. The offset to convert The corresponding @@ -297,9 +315,9 @@ Represents traits of the temporal context - + - if the temporal context provides a metronome feature - i.e., if it fires events. + A non- value if the temporal context provides a metronome feature - i.e., if it fires events. @@ -315,5 +333,41 @@ An event that can be raised to inform listeners that a metronome "tick" occurred. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ClockQuantizer.cs b/src/ClockQuantizer.cs index dcaffd8..fa85d45 100644 --- a/src/ClockQuantizer.cs +++ b/src/ClockQuantizer.cs @@ -1,5 +1,6 @@ using System; using System.Runtime.CompilerServices; +using System.Threading; using System.Threading.Tasks; namespace ClockQuantization @@ -12,9 +13,8 @@ namespace ClockQuantization /// Under certain conditions, an advance operation may be incurred by calls. public class ClockQuantizer : IAsyncDisposable, IDisposable { - private readonly ISystemClock _clock; + private readonly TemporalContextDriver _driver; private Interval? _currentInterval; - private System.Threading.Timer? _metronome; #region Fields & properties @@ -41,10 +41,10 @@ public class ClockQuantizer : IAsyncDisposable, IDisposable /// Returns the value of the reference clock. /// Depending on the actual reference clock implementation, this may or may not incur an expensive system call. - public DateTimeOffset UtcNow { get => _clock.UtcNow; } + public DateTimeOffset UtcNow { get => _driver.UtcNow; } /// Returns the value of the reference clock. - public long UtcNowClockOffset { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => _clock.UtcNowClockOffset; } + public long UtcNowClockOffset { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => _driver.UtcNowClockOffset; } #endregion @@ -57,7 +57,7 @@ public class ClockQuantizer : IAsyncDisposable, IDisposable /// The to convert /// An offset in clock-specific units. /// - public long DateTimeOffsetToClockOffset(DateTimeOffset offset) => _clock.DateTimeOffsetToClockOffset(offset); + public long DateTimeOffsetToClockOffset(DateTimeOffset offset) => _driver.DateTimeOffsetToClockOffset(offset); /// /// Converts an offset in clock-specific units (ticks) to a . @@ -66,7 +66,7 @@ public class ClockQuantizer : IAsyncDisposable, IDisposable /// A in UTC. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public DateTimeOffset ClockOffsetToUtcDateTimeOffset(long offset) => _clock.ClockOffsetToUtcDateTimeOffset(offset); + public DateTimeOffset ClockOffsetToUtcDateTimeOffset(long offset) => _driver.ClockOffsetToUtcDateTimeOffset(offset); /// /// Converts a to a count of clock-specific offset units (ticks). @@ -74,14 +74,14 @@ public class ClockQuantizer : IAsyncDisposable, IDisposable /// The to convert /// The amount of clock-specific offset units covering the . [MethodImpl(MethodImplOptions.AggressiveInlining)] - public long TimeSpanToClockOffsetUnits(TimeSpan timeSpan) => (long)(timeSpan.TotalMilliseconds * _clock.ClockOffsetUnitsPerMillisecond); + public long TimeSpanToClockOffsetUnits(TimeSpan timeSpan) => (long)(timeSpan.TotalMilliseconds * _driver.ClockOffsetUnitsPerMillisecond); /// /// Converts an amount of clock-specific offset units (ticks) to a . /// /// The amount of units to convert /// A covering the specified number of . - public TimeSpan ClockOffsetUnitsToTimeSpan(long units) => TimeSpan.FromMilliseconds((double)units / _clock.ClockOffsetUnitsPerMillisecond); + public TimeSpan ClockOffsetUnitsToTimeSpan(long units) => TimeSpan.FromMilliseconds((double)units / _driver.ClockOffsetUnitsPerMillisecond); #endregion @@ -218,33 +218,49 @@ internal NewIntervalEventArgs(DateTimeOffset offset, bool metronomic, TimeSpan? /// The maximum of each /// /// If also implements , the will pick up on external - /// events. Also, if is , + /// events. Also, if is non-, /// the will pick up on external events, instead of relying on an internal metronome. /// public ClockQuantizer(ISystemClock clock, TimeSpan maxIntervalTimeSpan) { - _clock = clock; - MaxIntervalTimeSpan = maxIntervalTimeSpan; - bool metronomic = true; + _driver = new TemporalContextDriver(clock, MaxIntervalTimeSpan = maxIntervalTimeSpan); + _driver.ClockAdjusted += Driver_ClockAdjusted; + _driver.MetronomeTicked += Driver_MetronomeTicked; + } - if (clock is ISystemClockTemporalContext context) - { - context.ClockAdjusted += Context_ClockAdjusted; - if (context.ProvidesMetronome) - { - // Allow external "pulse" on metronome ticks - context.MetronomeTicked += Context_MetronomeTicked; - metronomic = false; - } - } - if (metronomic) + // Quiescing + + private readonly object _quiescingLockObject = new object(); + + /// + /// Puts the into a quiescent state, effectively freeing any owned unmanaged resources. While in a quiescent state, the will not raise any events, nor perform metronomic advance operations. + /// + /// + /// Any externally initiated advance operation will automatically take the back into normal operation. + /// + public void Quiesce() + { + // Ensure that quiesent state can be achieved without immediately being knocked out of it by a MetronomeTicked event that just happened to be in flight. + lock (_quiescingLockObject) { - // Create a suspended timer. Timer will be started at first call to Advance(). - _metronome = new System.Threading.Timer(Metronome_TimerCallback, null, System.Threading.Timeout.InfiniteTimeSpan, maxIntervalTimeSpan); + _driver.Quiesce(); } } + /// + /// Takes the out of a quiescent state into normal operation. + /// + public void Unquiesce() => _driver.Unquiesce(); + + /// + /// Returns if the is in a quiescent state, otherwise. + /// + public bool IsQuiescent { get => _driver.IsQuiescent; } + + + // Advance primitives + private struct AdvancePreparationInfo { public Interval Interval; @@ -269,10 +285,10 @@ private Interval Advance(bool metronomic) private AdvancePreparationInfo PrepareAdvance(bool metronomic) { // Start metronome (if not imposed externally) on first Advance and consider first Advance as a metronomic event. - if (_currentInterval is null && _metronome is not null) + bool unquiescing = _driver.Unquiesce(); + if (unquiescing || _currentInterval is null) { metronomic = true; - _metronome.Change(MaxIntervalTimeSpan, MaxIntervalTimeSpan); } var previousInterval = _currentInterval; @@ -281,7 +297,7 @@ private AdvancePreparationInfo PrepareAdvance(bool metronomic) if (previousInterval is not null) { // Ignore potential *internal* metronome gap due to tiny clock jitter - if (!metronomic || _metronome is null) + if (unquiescing || !metronomic || (metronomic && !_driver.HasInternalMetronome)) { var gap = ClockOffsetUnitsToTimeSpan(interval.ClockOffset - previousInterval.ClockOffset) - MaxIntervalTimeSpan; if (gap > TimeSpan.Zero) @@ -291,7 +307,7 @@ private AdvancePreparationInfo PrepareAdvance(bool metronomic) } } - var e = new NewIntervalEventArgs(_clock.ClockOffsetToUtcDateTimeOffset(interval.ClockOffset), metronomic, detectedGap); + var e = new NewIntervalEventArgs(_driver.ClockOffsetToUtcDateTimeOffset(interval.ClockOffset), metronomic, detectedGap); return new AdvancePreparationInfo(interval, e); } @@ -303,7 +319,7 @@ private Interval CommitAdvance(AdvancePreparationInfo preparation) var e = preparation.EventArgs; if (e.IsMetronomic) { - NextMetronomicClockOffset = _clock.DateTimeOffsetToClockOffset(e.DateTimeOffset + MaxIntervalTimeSpan); + NextMetronomicClockOffset = _driver.DateTimeOffsetToClockOffset(e.DateTimeOffset + MaxIntervalTimeSpan); } OnAdvanced(e); @@ -316,17 +332,43 @@ private Interval CommitAdvance(AdvancePreparationInfo preparation) return preparation.Interval; } - private void Metronome_TimerCallback(object? _) => Context_MetronomeTicked(null, EventArgs.Empty); + private void Driver_MetronomeTicked(object? _, EventArgs __) + { + lock (_quiescingLockObject) + { + // Ensure that any in-flight MetronomeTicked event during quiescing transition does not knock us out of a quiesent state that was juuuuuuust established. + if (!IsQuiescent) + { + Advance(metronomic: true); + } + } + } - private void Context_MetronomeTicked(object? _, EventArgs __) => Advance(metronomic: true); + private void Driver_ClockAdjusted(object? _, EventArgs __) + { + // Allow clock adjustemts to take us out of quiesent state (race possible; small chance of in-flight event, as underlying driver is quisced as well). + Advance(metronomic: false); + } - private void Context_ClockAdjusted(object? _, EventArgs __) => Advance(metronomic: false); #region IAsyncDisposable/IDisposable + private int _areEventHandlersDetached; + private void DetachEventHandlers() + { + if (Interlocked.CompareExchange(ref _areEventHandlersDetached, 1, 0) == 0) + { + _driver.ClockAdjusted -= Driver_ClockAdjusted; + _driver.MetronomeTicked -= Driver_MetronomeTicked; + } + } + /// public void Dispose() { + // This method is re-entrant + DetachEventHandlers(); + Dispose(disposing: true); GC.SuppressFinalize(this); } @@ -334,6 +376,9 @@ public void Dispose() /// public async ValueTask DisposeAsync() { + // This method is re-entrant + DetachEventHandlers(); + await DisposeAsyncCore(); Dispose(disposing: false); @@ -345,39 +390,18 @@ protected virtual void Dispose(bool disposing) { if (disposing) { - _metronome?.Dispose(); + // This method is re-entrant and mutually co-existent with _driver.DisposeAsyncCore() + _driver.Dispose(); } - - _metronome = null; } /// protected virtual async ValueTask DisposeAsyncCore() { - if (_metronome is null) - { - goto done; - } - -#if NETSTANDARD2_1 || NETCOREAPP3_0 || NETCOREAPP3_1 || NET5_0 || NET5_0_OR_GREATER - if (_metronome is IAsyncDisposable asyncDisposable) - { - await asyncDisposable.DisposeAsync().ConfigureAwait(false); - goto finish; - } -#else - await default(ValueTask).ConfigureAwait(false); -#endif - _metronome!.Dispose(); - -#if NETSTANDARD2_1 || NETCOREAPP3_0 || NETCOREAPP3_1 || NET5_0 || NET5_0_OR_GREATER -finish: -#endif - _metronome = null; -done: - ; + // This method is re-entrant and mutually co-existent with _driver.Dispose() + await _driver.DisposeAsync().ConfigureAwait(false); } -#endregion + #endregion } } diff --git a/src/TemporalContext.cs b/src/TemporalContext.cs index 9b67403..513a5dd 100644 --- a/src/TemporalContext.cs +++ b/src/TemporalContext.cs @@ -27,7 +27,7 @@ public interface ISystemClock long ClockOffsetUnitsPerMillisecond { get; } /// - /// Converts to a in UTC. + /// Converts clock-specific to a in UTC. /// /// The offset to convert /// The corresponding @@ -47,9 +47,9 @@ public interface ISystemClock public interface ISystemClockTemporalContext { /// - /// if the temporal context provides a metronome feature - i.e., if it fires events. + /// A non- value if the temporal context provides a metronome feature - i.e., if it fires events. /// - bool ProvidesMetronome { get; } + TimeSpan? MetronomeIntervalTimeSpan { get; } /// /// An event that can be raised to inform listeners that the was adjusted. diff --git a/src/TemporalContextDriver.cs b/src/TemporalContextDriver.cs new file mode 100644 index 0000000..eb36b37 --- /dev/null +++ b/src/TemporalContextDriver.cs @@ -0,0 +1,269 @@ +using System; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + + +namespace ClockQuantization +{ + // Isolate some of the context/metronome madness from the core ClockQuantizer implementation + internal class TemporalContextDriver : ClockQuantization.ISystemClock, ISystemClockTemporalContext, IDisposable, IAsyncDisposable + { + private readonly ClockQuantization.ISystemClock _clock; + private readonly TimeSpan _metronomeIntervalTimeSpan; + private System.Threading.Timer? _metronome; + private EventArgs? _pendingClockAdjustedEventArgs; + + public TemporalContextDriver(ClockQuantization.ISystemClock clock, TimeSpan? metronomeIntervalTimeSpan = default) + { + AttachExternalTemporalContext(this, clock, out var externalMetronomeIntervalTimeSpan); + + if (externalMetronomeIntervalTimeSpan.HasValue) + { + IsQuiescent = false; + _metronomeIntervalTimeSpan = externalMetronomeIntervalTimeSpan.Value; + } + else + { + if (!metronomeIntervalTimeSpan.HasValue) + { + // Need metronome interval timespan + DetachExternalTemporalContext(); + throw new ArgumentNullException(nameof(metronomeIntervalTimeSpan), "Must provide a valid metronome interval timespan or supply an external metronome."); + } + + _metronomeIntervalTimeSpan = metronomeIntervalTimeSpan.Value; + } + + _clock = clock; + + static void AttachExternalTemporalContext(TemporalContextDriver driver, ClockQuantization.ISystemClock clock, out TimeSpan? externalMetronomeIntervalTimeSpan) + { + externalMetronomeIntervalTimeSpan = default; + + if (clock is ISystemClockTemporalContext context) + { + context.ClockAdjusted += driver.Context_ClockAdjusted; + if (context.MetronomeIntervalTimeSpan.HasValue) + { + externalMetronomeIntervalTimeSpan = context.MetronomeIntervalTimeSpan.Value; + // Allow external "pulse" on metronome ticks + context.MetronomeTicked += driver.Context_MetronomeTicked; + } + } + } + } + + private int _isExternalTemporalContextDetached; + private void DetachExternalTemporalContext() + { + if (Interlocked.CompareExchange(ref _isExternalTemporalContextDetached, 1, 0) == 0) + { + if (_clock is ISystemClockTemporalContext context) + { + context.ClockAdjusted -= Context_ClockAdjusted; + if (context.MetronomeIntervalTimeSpan.HasValue) + { + context.MetronomeTicked -= Context_MetronomeTicked; + } + } + } + } + + public bool HasInternalMetronome + { + get => _clock is not ISystemClockTemporalContext context || !context.MetronomeIntervalTimeSpan.HasValue; + } + + private void EnsureInternalMetronome() + { + if (_metronome is null && HasInternalMetronome) + { + // Create a paused metronome timer + var metronome = new Timer(Metronome_TimerCallback, null, Timeout.InfiniteTimeSpan, _metronomeIntervalTimeSpan); + if (Interlocked.CompareExchange(ref _metronome, metronome, null) is null) + { + // Resume the newly created metronome timer + metronome.Change(_metronomeIntervalTimeSpan, _metronomeIntervalTimeSpan); + } + else + { + // Wooops... another thread outpaced us... + metronome.Dispose(); + } + } + } + + private void DisposeInternalMetronome() + { + if (Interlocked.Exchange(ref _metronome, null) is Timer metronome) + { + metronome.Dispose(); + } + } + + private async ValueTask DisposeInternalMetronomeAsync() + { +#if !(NETSTANDARD2_1 || NETCOREAPP3_0 || NETCOREAPP3_1 || NET5_0 || NET5_0_OR_GREATER) + await default(ValueTask).ConfigureAwait(continueOnCapturedContext: false); +#endif + + if (Interlocked.Exchange(ref _metronome, null) is Timer metronome) + { +#if NETSTANDARD2_1 || NETCOREAPP3_0 || NETCOREAPP3_1 || NET5_0 || NET5_0_OR_GREATER + if (metronome is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync().ConfigureAwait(continueOnCapturedContext: false); + return; + } +#endif + metronome.Dispose(); + } + } + + public bool IsQuiescent { get; private set; } = true; + + public void Quiesce() + { + IsQuiescent = true; + + // Dispose of internal metronome, if applicable. + DisposeInternalMetronome(); + } + + private readonly object _unquiescingLockObject = new object(); + public bool Unquiesce() + { + bool unquiescing = IsQuiescent; + + if (unquiescing) + { + EventArgs? pendingClockAdjustedEventArgs = Interlocked.Exchange(ref _pendingClockAdjustedEventArgs, null); + + if (pendingClockAdjustedEventArgs is not null) + { + // Make sure that we briefly postpone any metronome event that may occur during the process of unquiescing + lock (_unquiescingLockObject) + { + IsQuiescent = false; // Set to false already to make sure that the ClockAdjusted event fires + OnClockAdjusted(pendingClockAdjustedEventArgs); + } + } + + IsQuiescent = false; + } + + // Ensure that we have an internal metronome, if applicable. + EnsureInternalMetronome(); + + return unquiescing; + } + + protected virtual void OnMetronomeTicked(EventArgs e) + { + if (!IsQuiescent) + { + // Make sure that we briefly postpone a metronome event that occurs during the process of unquiescing + lock (_unquiescingLockObject) + { + MetronomeTicked?.Invoke(this, e); + } + } + } + + protected virtual void OnClockAdjusted(EventArgs e) + { + if (!IsQuiescent) + { + ClockAdjusted?.Invoke(this, e); + } + else + { + // Retain the latest ClockAdjusted event until we unquiesce + Interlocked.Exchange(ref _pendingClockAdjustedEventArgs, e); + } + } + + private void Context_ClockAdjusted(object? _, EventArgs __) => OnClockAdjusted(EventArgs.Empty); + + private void Context_MetronomeTicked(object? _, EventArgs __) => OnMetronomeTicked(EventArgs.Empty); + + private void Metronome_TimerCallback(object? _) => OnMetronomeTicked(EventArgs.Empty); + + + #region ISystemClock + + /// + public DateTimeOffset UtcNow { get => _clock.UtcNow; } + /// + public long UtcNowClockOffset { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => _clock.UtcNowClockOffset; } + /// + public long ClockOffsetUnitsPerMillisecond { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => _clock.ClockOffsetUnitsPerMillisecond; } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public DateTimeOffset ClockOffsetToUtcDateTimeOffset(long offset) => _clock.ClockOffsetToUtcDateTimeOffset(offset); + /// + public long DateTimeOffsetToClockOffset(DateTimeOffset offset) => _clock.DateTimeOffsetToClockOffset(offset); + + #endregion + + #region ISystemClockTemporalContext + + /// + public TimeSpan? MetronomeIntervalTimeSpan { get => _metronomeIntervalTimeSpan; } + /// + public event EventHandler? ClockAdjusted; + /// + public event EventHandler? MetronomeTicked; + + #endregion + + #region IAsyncDisposable/IDisposable + + /// + public void Dispose() + { + IsQuiescent = true; + + // This method is re-entrant + DetachExternalTemporalContext(); + + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// + public async ValueTask DisposeAsync() + { + IsQuiescent = true; + + // This method is re-entrant + DetachExternalTemporalContext(); + + await DisposeAsyncCore(); + + Dispose(disposing: false); + GC.SuppressFinalize(this); + } + + /// + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + // This method is re-entrant + DisposeInternalMetronome(); + } + } + + /// + protected virtual async ValueTask DisposeAsyncCore() + { + // This method is re-entrant + await DisposeInternalMetronomeAsync(); + } + + #endregion + } +} diff --git a/tests/ClockQuantization.Tests/assets/SystemClockTemporalContext.cs b/tests/ClockQuantization.Tests/assets/SystemClockTemporalContext.cs index cf9d5fa..25abdf5 100644 --- a/tests/ClockQuantization.Tests/assets/SystemClockTemporalContext.cs +++ b/tests/ClockQuantization.Tests/assets/SystemClockTemporalContext.cs @@ -47,7 +47,7 @@ public void AdjustClock(DateTimeOffset now) private System.Threading.Timer? _metronome; private MetronomeOptions? _metronomeOptions; - public bool ProvidesMetronome { get; private set; } + public TimeSpan? MetronomeIntervalTimeSpan { get; private set; } = default; public bool IsMetronomeRunning { get; private set; } public event EventHandler? ClockAdjusted; @@ -89,8 +89,12 @@ public SystemClockTemporalContext(MetronomeOptions? metronomeOptions) : this(new public SystemClockTemporalContext(Func getUtcNow, MetronomeOptions? metronomeOptions) { GetUtcNow = getUtcNow; - ProvidesMetronome = (_metronomeOptions = metronomeOptions) is not null; - IsMetronomeRunning = ProvidesMetronome && ApplyMetronomeOptions(metronomeOptions!, Metronome_Ticked, out _metronome); + if ((_metronomeOptions = metronomeOptions) is not null) + { + MetronomeIntervalTimeSpan = metronomeOptions!.MaxIntervalTimeSpan; + } + + IsMetronomeRunning = MetronomeIntervalTimeSpan.HasValue && ApplyMetronomeOptions(metronomeOptions!, Metronome_Ticked, out _metronome); #if NET5_0 || NET5_0_OR_GREATER var utcNow = DateTimeOffset.UtcNow;