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;