Created
July 20, 2025 00:45
-
-
Save jnm2/73e916beebd59b11e60fcd5a29b02e11 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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