Skip to content

Instantly share code, notes, and snippets.

@jnm2
Created July 20, 2025 00:45
Show Gist options
  • Save jnm2/73e916beebd59b11e60fcd5a29b02e11 to your computer and use it in GitHub Desktop.
Save jnm2/73e916beebd59b11e60fcd5a29b02e11 to your computer and use it in GitHub Desktop.
public sealed class Debouncer : IAsyncDisposable
{
private readonly TimeSpan delay;
private readonly Action action;
private readonly ITimer timer;
private bool isWaitingOrActing;
public Debouncer(TimeSpan delay, Action action, TimeProvider? timeProvider = null)
{
this.delay = delay;
this.action = action;
timeProvider ??= TimeProvider.System;
timer = timeProvider.CreateTimer(OnTimerCallback, null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
}
private void OnTimerCallback(object? state)
{
try
{
action();
}
finally
{
Volatile.Write(ref isWaitingOrActing, false);
}
}
/// <summary>
/// Starts a timer that will invoke the action after the specified delay. If the timer is already running, or if the
/// action is still executing, this call will have no effect.
/// </summary>
public void Signal()
{
if (!Interlocked.Exchange(ref isWaitingOrActing, true))
timer.Change(delay, Timeout.InfiniteTimeSpan);
}
public async ValueTask DisposeAsync()
{
await timer.DisposeAsync();
}
}
using Shouldly;
public sealed class DebouncerTests
{
[Test]
public async Task Debouncer_invokes_action_after_delay()
{
var timeProvider = new TestTimeProvider();
var actionCallCount = 0;
await using var debouncer = new Debouncer(TimeSpan.FromSeconds(1), () => actionCallCount++, timeProvider);
var timer = timeProvider.Timers.ShouldHaveSingleItem();
debouncer.Signal();
timer.DueTime.ShouldBe(TimeSpan.FromSeconds(1));
timer.Period.ShouldBe(Timeout.InfiniteTimeSpan);
actionCallCount.ShouldBe(0);
timer.Fire();
actionCallCount.ShouldBe(1);
}
[Test]
public async Task Signaling_multiple_times_during_delay_does_not_invoke_action_multiple_times()
{
var timeProvider = new TestTimeProvider();
var actionCallCount = 0;
await using var debouncer = new Debouncer(TimeSpan.FromSeconds(1), () => actionCallCount++, timeProvider);
var timer = timeProvider.Timers.ShouldHaveSingleItem();
debouncer.Signal();
timer.DueTime.ShouldBe(TimeSpan.FromSeconds(1));
timer.Period.ShouldBe(Timeout.InfiniteTimeSpan);
debouncer.Signal();
timer.DueTime.ShouldBe(TimeSpan.FromSeconds(1));
timer.Period.ShouldBe(Timeout.InfiniteTimeSpan);
debouncer.Signal();
timer.DueTime.ShouldBe(TimeSpan.FromSeconds(1));
timer.Period.ShouldBe(Timeout.InfiniteTimeSpan);
actionCallCount.ShouldBe(0);
timer.Fire();
actionCallCount.ShouldBe(1);
timer.DueTime.ShouldBe(Timeout.InfiniteTimeSpan);
}
[Test]
public async Task Signals_are_ignored_while_action_is_still_executing()
{
var timeProvider = new TestTimeProvider();
using var actionBlocker = new ManualResetEventSlim(false);
var actionCallCount = 0;
var actionStarted = new TaskCompletionSource();
await using var debouncer = new Debouncer(TimeSpan.FromSeconds(1), () =>
{
actionCallCount++;
actionStarted.SetResult();
actionBlocker.Wait();
}, timeProvider);
var timer = timeProvider.Timers.ShouldHaveSingleItem();
debouncer.Signal();
timer.DueTime.ShouldBe(TimeSpan.FromSeconds(1));
var fireTask = Task.Run(timer.Fire);
await actionStarted.Task;
timer.DueTime.ShouldBe(Timeout.InfiniteTimeSpan);
debouncer.Signal();
timer.DueTime.ShouldBe(Timeout.InfiniteTimeSpan);
actionBlocker.Set();
await fireTask;
actionCallCount.ShouldBe(1);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment