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 {
0 commit comments