Skip to content

Instantly share code, notes, and snippets.

@disintegrator
Created December 23, 2021 18:52
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save disintegrator/be65e25723f836c1048175b74d127e73 to your computer and use it in GitHub Desktop.
Save disintegrator/be65e25723f836c1048175b74d127e73 to your computer and use it in GitHub Desktop.
A debouncer that sends pulses on a channel (Golang, Go)
package debounce
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
// stand-in for something better e.g. github.com/pkg/errors
type debouncerError struct {
current error
parent error
}
func (e *debouncerError) Error() string {
if e.parent == nil {
return e.current.Error()
}
return fmt.Sprintf("%s: %s", e.parent.Error(), e.current.Error())
}
func (e *debouncerError) Is(target error) bool {
isParent := false
if e.parent != nil {
isParent = target == e.parent
}
return target == e.current || isParent
}
var (
ErrDebouncerClosed = errors.New("debouncer is no longer running")
)
type DebounceOptions struct {
Period time.Duration
}
func Channel(ctx context.Context, options *DebounceOptions) (debouncer func() error, pulseC <-chan struct{}) {
var mut sync.Mutex
var timer *time.Timer
pulse := make(chan struct{}, 1)
pulseC = pulse
debouncer = func() error {
mut.Lock()
defer mut.Unlock()
if timer != nil {
timer.Stop()
}
select {
case <-ctx.Done():
return &debouncerError{current: ErrDebouncerClosed, parent: ctx.Err()}
default:
}
timer = time.AfterFunc(options.Period, func() {
select {
case pulse <- struct{}{}:
default:
// If we are unable to write a value then we assume there is either:
// - No receiver. In which case, drop the pulses since there is already
// one buffered.
// - Slow receiver. In which case, drop the pulses since they will
// eventually pick up the buffered pulse.
}
})
return nil
}
go func() {
<-ctx.Done()
mut.Lock()
defer mut.Unlock()
if timer != nil {
timer.Stop()
}
}()
return
}
package debounce
import (
"context"
"errors"
"testing"
"time"
)
func TestChannel(t *testing.T) {
debouncer, pulseC := Channel(context.Background(), &DebounceOptions{
Period: 50 * time.Millisecond,
})
for i := 0; i < 10; i++ {
time.Sleep(25 * time.Millisecond)
if err := debouncer(); err != nil {
t.Fatalf("unexpected error: %s", err)
}
}
select {
case <-pulseC:
t.Fatal("did not expect to receive on pulse channel")
default:
// noop
}
time.Sleep(60 * time.Millisecond)
select {
case <-pulseC:
// noop
default:
t.Fatal("expected to receive on pulse channel")
}
}
func TestChannel_NoActivity(t *testing.T) {
_, pulseC := Channel(context.Background(), &DebounceOptions{
Period: 50 * time.Millisecond,
})
time.Sleep(60 * time.Millisecond)
select {
case <-pulseC:
t.Fatal("did not expect to receive on pulse channel")
default:
// noop
}
}
func TestChannel_Closed(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
debouncer, pulseC := Channel(ctx, &DebounceOptions{
Period: 50 * time.Millisecond,
})
cancel()
if err := debouncer(); !errors.Is(err, ErrDebouncerClosed) || !errors.Is(err, context.Canceled) {
t.Fatalf("did not get the desired error. got: %s", err)
}
time.Sleep(60 * time.Millisecond)
select {
case <-pulseC:
t.Fatal("did not expect to receive on pulse channel")
default:
// noop
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment