Skip to content

Commit 54f42f8

Browse files
committed
Refactor: Improve debouncer logic and add throttled debouncer support
1 parent bd6b56a commit 54f42f8

File tree

5 files changed

+151
-78
lines changed

5 files changed

+151
-78
lines changed
Lines changed: 53 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// ---------------------------------------------------------------------------------------------------------------------
22
// Imports
33
// ---------------------------------------------------------------------------------------------------------------------
4+
using System.Diagnostics.CodeAnalysis;
5+
46
namespace CodeOfChaos.Extensions.Debouncers;
57

68
// ---------------------------------------------------------------------------------------------------------------------
@@ -9,56 +11,70 @@ namespace CodeOfChaos.Extensions.Debouncers;
911
public abstract class DebouncerBase<T> : IAsyncDisposable {
1012
protected const int DefaultDebounceMs = 100;
1113
protected int DebounceMs { get; init; }
12-
14+
1315
private readonly SemaphoreSlim _semaphore = new(1, 1);
16+
private readonly Lock _stateLock = new();
1417
private CancellationTokenSource? _cts;
1518
private Task? _debounceTask;
1619
private bool _isDisposed;
1720
private T? _latestValue;
1821

1922
public abstract bool IsEmpty { get; }
20-
2123
// -----------------------------------------------------------------------------------------------------------------
2224
// Methods
2325
// -----------------------------------------------------------------------------------------------------------------
2426
protected abstract ValueTask InvokeCallbackAsync(T item, CancellationToken ct = default);
25-
26-
// ReSharper disable once PossiblyMistakenUseOfCancellationToken
27+
28+
// ReSharper disable once InconsistentlySynchronizedField
2729
protected async Task DebouncerLogicAsync(T? value = default, CancellationToken ct = default) {
28-
ObjectDisposedException.ThrowIf(_isDisposed, this);
30+
EnsureNotDisposed();
31+
2932
if (IsEmpty) return;
3033

3134
await _semaphore.WaitAsync(ct);
3235
try {
3336
_latestValue = value;
3437

38+
ReplaceCancellationTokenSource();
39+
_debounceTask = ExecuteDebounceTaskAsync(_latestValue, _cts.Token);
40+
}
41+
finally {
42+
_semaphore.Release();
43+
}
44+
45+
// Awaiting the debounce task is not done here to allow non-blocking execution of this method.
46+
}
47+
48+
[MemberNotNull(nameof(_cts))]
49+
private void ReplaceCancellationTokenSource() {
50+
lock (_stateLock) {
3551
if (_cts is not null) {
36-
await _cts.CancelAsync();
52+
_cts.Cancel();
3753
_cts.Dispose();
3854
}
3955

4056
_cts = new CancellationTokenSource();
41-
CancellationTokenSource? localCts = _cts;
42-
CancellationToken debounceToken = localCts.Token;
43-
44-
_debounceTask = Task.Run(function: async () => {
45-
try {
46-
await Task.Delay(DebounceMs, debounceToken);
47-
if (debounceToken.IsCancellationRequested) return;
48-
await InvokeCallbackAsync(_latestValue!, ct);
49-
50-
}
51-
catch (OperationCanceledException) {
52-
// Ignore
53-
}
54-
}, debounceToken);
57+
}
58+
}
5559

60+
private async Task ExecuteDebounceTaskAsync(T? capturedValue, CancellationToken debounceToken) {
61+
try {
62+
await Task.Delay(DebounceMs, debounceToken);
63+
64+
if (!debounceToken.IsCancellationRequested && capturedValue is not null) {
65+
await InvokeCallbackAsync(capturedValue, debounceToken);
66+
_debounceTask = null;
67+
}
5668
}
57-
finally {
58-
_semaphore.Release();
69+
catch (OperationCanceledException) {
70+
// Ignored: task was canceled before completion
5971
}
6072
}
61-
73+
74+
private void EnsureNotDisposed() {
75+
if (!_isDisposed) return;
76+
throw new ObjectDisposedException(nameof(DebouncerBase<T>));
77+
}
6278

6379
public async ValueTask DisposeAsync() {
6480
if (_isDisposed) return;
@@ -67,22 +83,28 @@ public async ValueTask DisposeAsync() {
6783

6884
await _semaphore.WaitAsync();
6985
try {
70-
if (_cts is not null) {
71-
await _cts.CancelAsync();
72-
_cts.Dispose();
73-
}
74-
7586
if (_debounceTask is not null) {
76-
try { await _debounceTask; }
87+
try {
88+
await _debounceTask;
89+
}
7790
catch {
78-
// Ignore
91+
// Ignore task exceptions
92+
}
93+
}
94+
95+
lock (_stateLock) {
96+
if (_cts is not null) {
97+
_cts.Cancel();
98+
_cts.Dispose();
99+
_cts = null;
79100
}
80101
}
81102
}
82103
finally {
104+
_semaphore.Release();
83105
_semaphore.Dispose();
84106
}
85107

86108
GC.SuppressFinalize(this);
87109
}
88-
}
110+
}

src/CodeOfChaos.Extensions/Debouncers/Regular/DebouncerExtensions.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,4 @@ public static class DebouncerExtensions {
1717
public static Debouncer GetDebouncer(this Func<Task> callback, int debounceMs) => Debouncer.FromDelegate(callback, debounceMs);
1818
public static Debouncer<T> GetDebouncer<T>(this Func<T, CancellationToken, Task> callback, int debounceMs) => Debouncer<T>.FromDelegate(callback, debounceMs);
1919
public static Debouncer GetDebouncer(this Func<CancellationToken, Task> callback, int debounceMs) => Debouncer.FromDelegate(callback, debounceMs);
20-
2120
}

src/CodeOfChaos.Extensions/Debouncers/Throttled/ThrottledDebouncerBase.cs

Lines changed: 77 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
// ---------------------------------------------------------------------------------------------------------------------
22
// Imports
33
// ---------------------------------------------------------------------------------------------------------------------
4-
namespace CodeOfChaos.Extensions.Debouncers;
4+
using System.Diagnostics.CodeAnalysis;
55

6+
namespace CodeOfChaos.Extensions.Debouncers;
67
// ---------------------------------------------------------------------------------------------------------------------
78
// Code
89
// ---------------------------------------------------------------------------------------------------------------------
@@ -12,8 +13,9 @@ public abstract class ThrottledDebouncerBase<T> : IAsyncDisposable {
1213

1314
protected int DebounceMs { get; init; } = DefaultDebounceMs;
1415
protected int ThrottleMs { get; init; } = DefaultThrottleMs;
15-
16+
1617
private readonly SemaphoreSlim _semaphore = new(1, 1);
18+
private readonly Lock _stateLock = new();
1719
private CancellationTokenSource? _cts;
1820
private Task? _debounceTask;
1921
private bool _isDisposed;
@@ -22,80 +24,110 @@ public abstract class ThrottledDebouncerBase<T> : IAsyncDisposable {
2224
private DateTime _firstCallTime = DateTime.MinValue;
2325

2426
public abstract bool IsEmpty { get; }
25-
2627
// -----------------------------------------------------------------------------------------------------------------
2728
// Methods
2829
// -----------------------------------------------------------------------------------------------------------------
2930
protected abstract ValueTask InvokeCallbackAsync(T item, CancellationToken ct = default);
3031

31-
// ReSharper disable twice PossiblyMistakenUseOfCancellationToken
3232
protected async Task DebouncerLogicAsync(T? value = default, CancellationToken ct = default) {
33-
ObjectDisposedException.ThrowIf(_isDisposed, this);
33+
EnsureNotDisposed();
34+
3435
if (IsEmpty) return;
3536

3637
await _semaphore.WaitAsync(ct);
3738
try {
3839
_latestValue = value;
39-
if (_firstCallTime == DateTime.MinValue) {
40-
_firstCallTime = DateTime.UtcNow;
41-
}
4240

41+
if (_firstCallTime == DateTime.MinValue) _firstCallTime = DateTime.UtcNow;
42+
43+
ReplaceCancellationTokenSource();
44+
_debounceTask = ExecuteDebounceTaskAsync(_latestValue, _cts.Token);
45+
}
46+
finally {
47+
_semaphore.Release();
48+
}
49+
50+
// Note: DebouncerLogicAsync doesn't await the task to avoid blocking; the task is managed internally.
51+
}
52+
53+
[MemberNotNull(nameof(_cts))]
54+
private void ReplaceCancellationTokenSource() {
55+
lock (_stateLock) {
4356
if (_cts is not null) {
44-
await _cts.CancelAsync();
57+
_cts.Cancel();
4558
_cts.Dispose();
4659
}
4760

4861
_cts = new CancellationTokenSource();
49-
CancellationTokenSource? localCts = _cts;
50-
CancellationToken debounceToken = localCts.Token;
51-
52-
_debounceTask = Task.Run(async () => {
53-
try {
54-
DateTime taskStartTime = DateTime.UtcNow;
55-
56-
DateTime referenceTime = _lastExecuteTime != DateTime.MinValue ? _lastExecuteTime : _firstCallTime;
57-
double timeSinceReference = (taskStartTime - referenceTime).TotalMilliseconds;
58-
59-
// Execute immediately due to throttling
60-
if (timeSinceReference >= ThrottleMs) {
61-
_lastExecuteTime = taskStartTime;
62-
await InvokeCallbackAsync(_latestValue!, ct);
63-
return;
64-
}
65-
66-
await Task.Delay(DebounceMs, debounceToken);
67-
68-
if (debounceToken.IsCancellationRequested) return;
69-
_lastExecuteTime = taskStartTime;
70-
await InvokeCallbackAsync(_latestValue!, ct);
71-
}
72-
catch (OperationCanceledException) {
73-
// Ignore cancellation
74-
}
75-
}, debounceToken);
7662
}
77-
finally {
78-
_semaphore.Release();
63+
}
64+
65+
private async Task ExecuteDebounceTaskAsync(T? capturedValue, CancellationToken externalCt) {
66+
try {
67+
DateTime taskStartTime = DateTime.UtcNow;
68+
69+
DateTime referenceTime;
70+
lock (_stateLock) {
71+
referenceTime = _lastExecuteTime != DateTime.MinValue ? _lastExecuteTime : _firstCallTime;
72+
}
73+
74+
double timeSinceReference = (taskStartTime - referenceTime).TotalMilliseconds;
75+
76+
// Execute immediately if throttling conditions are met
77+
if (timeSinceReference >= ThrottleMs) {
78+
await HandleDebounceExecution(capturedValue, externalCt, taskStartTime);
79+
return;
80+
}
81+
82+
// Wait for the debounce delay
83+
await Task.Delay(DebounceMs, _cts!.Token);
84+
85+
if (_cts.Token.IsCancellationRequested) return;
86+
await HandleDebounceExecution(capturedValue, externalCt, taskStartTime);
87+
}
88+
catch (OperationCanceledException) {
89+
// Ignore cancellation
7990
}
8091
}
81-
92+
private async Task HandleDebounceExecution(T? capturedValue, CancellationToken externalCt, DateTime taskStartTime) {
93+
lock (_stateLock) {
94+
_lastExecuteTime = taskStartTime;
95+
}
96+
if (capturedValue is null) return;
97+
98+
await InvokeCallbackAsync(capturedValue, externalCt);
99+
_debounceTask = null;
100+
101+
}
102+
103+
private void EnsureNotDisposed() {
104+
if (!_isDisposed) return;
105+
106+
throw new ObjectDisposedException(nameof(DebouncerBase<T>));
107+
}
82108
public async ValueTask DisposeAsync() {
83109
if (_isDisposed) return;
84110

85111
_isDisposed = true;
86112

87113
await _semaphore.WaitAsync();
88114
try {
115+
// Ensure the active debounce task completes
89116
if (_debounceTask is not null) {
90-
try { await _debounceTask; }
117+
try {
118+
await _debounceTask;
119+
}
91120
catch {
92-
// Ignore
121+
// Ignore task exceptions
93122
}
94123
}
95-
96-
if (_cts is not null) {
97-
await _cts.CancelAsync();
98-
_cts.Dispose();
124+
125+
lock (_stateLock) {
126+
if (_cts is not null) {
127+
_cts.Cancel();
128+
_cts.Dispose();
129+
_cts = null;
130+
}
99131
}
100132
}
101133
finally {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// ---------------------------------------------------------------------------------------------------------------------
2+
// Imports
3+
// ---------------------------------------------------------------------------------------------------------------------
4+
using CodeOfChaos.Extensions.Debouncers;
5+
6+
// ReSharper disable once CheckNamespace
7+
namespace System;
8+
9+
// ---------------------------------------------------------------------------------------------------------------------
10+
// Code
11+
// ---------------------------------------------------------------------------------------------------------------------
12+
public static class ThrottledDebouncerExtensions {
13+
public static ThrottledDebouncer<T> GetThrottledDebouncer<T>(this Action<T> callback, int debounceMs, int throttleMs) => ThrottledDebouncer<T>.FromDelegate(callback, debounceMs, throttleMs);
14+
public static ThrottledDebouncer GetThrottledDebouncer(this Action callback, int debounceMs, int throttleMs) => ThrottledDebouncer.FromDelegate(callback, debounceMs, throttleMs);
15+
16+
public static ThrottledDebouncer<T> GetThrottledDebouncer<T>(this Func<T, Task> callback, int debounceMs, int throttleMs) => ThrottledDebouncer<T>.FromDelegate(callback, debounceMs, throttleMs);
17+
public static ThrottledDebouncer GetThrottledDebouncer(this Func<Task> callback, int debounceMs, int throttleMs) => ThrottledDebouncer.FromDelegate(callback, debounceMs, throttleMs);
18+
public static ThrottledDebouncer<T> GetThrottledDebouncer<T>(this Func<T, CancellationToken, Task> callback, int debounceMs, int throttleMs) => ThrottledDebouncer<T>.FromDelegate(callback, debounceMs, throttleMs);
19+
public static ThrottledDebouncer GetThrottledDebouncer(this Func<CancellationToken, Task> callback, int debounceMs, int throttleMs) => ThrottledDebouncer.FromDelegate(callback, debounceMs, throttleMs);
20+
}

tests/Tests.CodeOfChaos.Extensions.AspNetCore.Components/EventCallbacks/EventCallbackDebouncerGenericTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public async Task GenericDebouncer_DefaultValue_ShouldWork() {
4343
await Task.Delay(150);
4444

4545
// Assert
46-
await Assert.That(receivedValue).IsNull();
46+
await Assert.That(receivedValue).IsEqualTo("initial");
4747
}
4848

4949
[Test]

0 commit comments

Comments
 (0)