Skip to content

Instantly share code, notes, and snippets.

@neon-sunset
Created April 4, 2026 11:22
Show Gist options
  • Select an option

  • Save neon-sunset/0ffe9fa8cd17210ed6f41e7310569e06 to your computer and use it in GitHub Desktop.

Select an option

Save neon-sunset/0ffe9fa8cd17210ed6f41e7310569e06 to your computer and use it in GitHub Desktop.
Correct by construction wrapper for PeriodicTimer
/// <summary>
/// Represents a periodic cycle that executes a callback at regular intervals using PeriodicTimer.
/// </summary>
public sealed class Cycle : IAsyncDisposable
{
readonly Task cycleTask;
readonly CancellationTokenSource cancellation;
volatile bool disposed;
Cycle(TimeSpan period, Action callback, TimeSpan? initialDelay, Action<Exception>? onError)
{
ArgumentNullException.ThrowIfNull(callback);
cancellation = new CancellationTokenSource();
cycleTask = onError == null
? Run(callback, initialDelay, period, cancellation.Token)
: RunWithErrorHandler(callback, initialDelay, period, onError, cancellation.Token);
}
Cycle(TimeSpan period, Func<CancellationToken, ValueTask> callback, TimeSpan? initialDelay, Action<Exception>? onError)
{
ArgumentNullException.ThrowIfNull(callback);
cancellation = new CancellationTokenSource();
cycleTask = onError == null
? Run(callback, initialDelay, period, cancellation.Token)
: RunWithErrorHandler(callback, initialDelay, period, onError, cancellation.Token);
}
/// <summary>
/// Starts a new periodic cycle with a synchronous callback.
/// </summary>
/// <param name="period">The time interval between callback executions.</param>
/// <param name="callback">The callback to execute on each cycle.</param>
/// <param name="initialDelay">Optional initial delay before the first execution. If null, waits for one period.</param>
/// <returns>A new Cycle instance.</returns>
public static Cycle Start(
TimeSpan period,
Action callback,
TimeSpan? initialDelay = null)
{
return new Cycle(period, callback, initialDelay, null);
}
/// <summary>
/// Starts a new periodic cycle with an asynchronous callback.
/// </summary>
/// <param name="period">The time interval between callback executions.</param>
/// <param name="callback">The asynchronous callback to execute on each cycle.</param>
/// <param name="initialDelay">Optional initial delay before the first execution. If null, waits for one period.</param>
/// <returns>A new Cycle instance.</returns>
public static Cycle Start(
TimeSpan period,
Func<CancellationToken, ValueTask> callback,
TimeSpan? initialDelay = null)
{
return new Cycle(period, callback, initialDelay, null);
}
/// <summary>
/// Starts a new periodic cycle with a synchronous callback.
/// </summary>
/// <param name="period">The time interval between callback executions.</param>
/// <param name="callback">The callback to execute on each cycle.</param>
/// <param name="initialDelay">Optional initial delay before the first execution. If null, waits for one period.</param>
/// <param name="onError">Optional callback to handle exceptions from the main callback. If null, exceptions will terminate the cycle.</param>
/// <returns>A new Cycle instance.</returns>
public static Cycle Start(
TimeSpan period,
Action callback,
TimeSpan? initialDelay,
Action<Exception>? onError)
{
return new Cycle(period, callback, initialDelay, onError);
}
/// <summary>
/// Starts a new periodic cycle with an asynchronous callback.
/// </summary>
/// <param name="period">The time interval between callback executions.</param>
/// <param name="callback">The asynchronous callback to execute on each cycle.</param>
/// <param name="initialDelay">Optional initial delay before the first execution. If null, waits for one period.</param>
/// <param name="onError">Optional callback to handle exceptions from the main callback. If null, exceptions will terminate the cycle.</param>
/// <returns>A new Cycle instance.</returns>
public static Cycle Start(
TimeSpan period,
Func<CancellationToken, ValueTask> callback,
TimeSpan? initialDelay,
Action<Exception>? onError)
{
return new Cycle(period, callback, initialDelay, onError);
}
static async Task Run(
Action callback,
TimeSpan? initialDelay,
TimeSpan period,
CancellationToken ct)
{
await Task.Delay(initialDelay ?? period, ct).ConfigureAwait(false);
using var timer = new PeriodicTimer(period);
do callback();
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false));
}
static async Task Run(
Func<CancellationToken, ValueTask> callback,
TimeSpan? initialDelay,
TimeSpan period,
CancellationToken ct)
{
await Task.Delay(initialDelay ?? period, ct).ConfigureAwait(false);
using var timer = new PeriodicTimer(period);
do await callback(ct).ConfigureAwait(false);
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false));
}
static async Task RunWithErrorHandler(
Action callback,
TimeSpan? initialDelay,
TimeSpan period,
Action<Exception> onError,
CancellationToken ct)
{
await Task.Delay(initialDelay ?? period, ct).ConfigureAwait(false);
using var timer = new PeriodicTimer(period);
do
{
try { callback(); }
catch (Exception ex) when (!ct.IsCancellationRequested)
{
onError(ex);
}
}
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false));
}
static async Task RunWithErrorHandler(
Func<CancellationToken, ValueTask> callback,
TimeSpan? initialDelay,
TimeSpan period,
Action<Exception> onError,
CancellationToken ct)
{
await Task.Delay(initialDelay ?? period, ct).ConfigureAwait(false);
using var timer = new PeriodicTimer(period);
do
{
try { await callback(ct).ConfigureAwait(false); }
catch (Exception ex) when (!ct.IsCancellationRequested)
{
onError(ex);
}
}
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false));
}
/// <summary>
/// Stops the cycle and disposes all resources asynchronously.
/// </summary>
public async ValueTask DisposeAsync()
{
if (disposed)
return;
disposed = true;
cancellation.Cancel();
await cycleTask
.WaitAsync(TimeSpan.FromSeconds(30))
.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
cancellation.Dispose();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment