Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add silence period a.k.a. debounce #101

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions conf/conf.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"sort"
"time"
)

// A Daemon is a persistent process that is kept running
Expand All @@ -27,6 +28,7 @@ type Block struct {

Daemons []Daemon
Preps []Prep
Silence *Silence
}

func (b *Block) addPrep(command string, options []string) error {
Expand All @@ -50,6 +52,38 @@ func (b *Block) addPrep(command string, options []string) error {
return nil
}

func (b *Block) addSilence(value string, options []string) error {
if b.Silence != nil {
return fmt.Errorf("silence can only be used once per block")
}

duration, err := time.ParseDuration(value)
if err != nil {
return fmt.Errorf("can't parse duration `%s`: %s", value, err)
}

// We already have the onchange prop in prep. Though, the semantics of 'onchange' for silence is not clear for me.
// TODO: may be rename this to onstart.
var onchange = false
for _, v := range options {
switch v {
case "+onchange":
onchange = true
default:
return fmt.Errorf("unknown option: %s", v)
}
}

last := time.Now()
if !onchange {
// pretend we've been triggered some time earlier
last = last.Add(-duration)
}

b.Silence = &Silence{last, duration}
return nil
}

// Config represents a complete configuration
type Config struct {
Blocks []Block
Expand Down
6 changes: 6 additions & 0 deletions conf/lex.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const (
itemQuotedString
itemPrep
itemRightParen
itemSilence
itemSpace
itemVarName
itemEquals
Expand Down Expand Up @@ -66,6 +67,8 @@ func (i itemType) String() string {
return "quotedstring"
case itemRightParen:
return "rparen"
case itemSilence:
return "silence"
case itemSpace:
return "space"
case itemVarName:
Expand Down Expand Up @@ -432,6 +435,9 @@ func lexInside(l *lexer) stateFn {
case "prep":
l.emit(itemPrep)
return lexOptions
case "silence":
l.emit(itemSilence)
return lexOptions
default:
l.errorf("unknown directive: %s", l.current())
return nil
Expand Down
9 changes: 9 additions & 0 deletions conf/lex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,15 @@ var lexTests = []struct {
{itemBareString, "b"},
},
},
{
"{\nsilence: 1us\n}\n", []itm{
{itemLeftParen, "{"},
{itemSilence, "silence"},
{itemColon, ":"},
{itemBareString, "1us\n"},
{itemRightParen, "}"},
},
},
}

func TestLex(t *testing.T) {
Expand Down
10 changes: 10 additions & 0 deletions conf/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,16 @@ Loop:
if err != nil {
p.errorf("%s", err)
}
case itemSilence:
options := p.collectValues(itemBareString)
p.mustNext(itemColon)
err := block.addSilence(
prepValue(p.mustNext(itemBareString)),
options,
)
if err != nil {
p.errorf("%s", err)
}
case itemRightParen:
break Loop
default:
Expand Down
37 changes: 37 additions & 0 deletions conf/silence.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package conf

import (
"fmt"
"time"
)

// A Silence (a.k.a debounce) denotes how much time should pass after last change to start the block
type Silence struct {
LastTime time.Time
Duration time.Duration // Silence interval duration from last change
}

func (s *Silence) String() string {
if s == nil {
return "<nil>"
}
return fmt.Sprintf("<silence for %v, last %v, %v remains>", s.Duration, s.LastTime, s.Duration - time.Since(s.LastTime))
}

// Ready checks if the Silence's timeout passed and aim it again if needed.
func (s *Silence) Ready() bool {
if s == nil {
return true
}

if s.Duration == time.Duration(0) {
return true
}

if time.Since(s.LastTime) >= s.Duration {
s.LastTime = time.Now()
return true
}

return false
}
2 changes: 1 addition & 1 deletion daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func (d *daemon) Run() {

// If we exited cleanly, or the process ran for > MaxRestart, we reset
// the delay timer
if time.Now().Sub(lastStart) > MaxRestart {
if time.Since(lastStart) > MaxRestart {
delay = MinRestart
} else {
delay *= MulRestart
Expand Down
4 changes: 4 additions & 0 deletions modd.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@ func (mr *ModRunner) runBlock(b conf.Block, mod *moddwatch.Mod, dpen *DaemonPen)

func (mr *ModRunner) trigger(root string, mod *moddwatch.Mod, dworld *DaemonWorld) {
for i, b := range mr.Config.Blocks {
if !b.Silence.Ready() {
mr.Log.Notice("silence period effective: %s", b.Silence)
continue
}
lmod := mod
if lmod != nil {
var err error
Expand Down
103 changes: 102 additions & 1 deletion modd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ func _testWatch(t *testing.T, modfunc func(), expected []string) {
if reflect.DeepEqual(ret, expected) {
break
}
if time.Now().Sub(start) > timeout {
if time.Since(start) > timeout {
t.Errorf("Expected\n%#v\nGot\n%#v", expected, ret)
break
}
Expand Down Expand Up @@ -225,3 +225,104 @@ func TestWatch(t *testing.T) {
},
)
}

func TestSilence(t *testing.T) {
t.Run(
"immediate",
func(t *testing.T) {
_testSilence(
t,
func() {
touch("immediate")
time.Sleep(2 * time.Millisecond)
touch("immediate")
time.Sleep(11 * time.Millisecond)
touch("immediate")
},
[]string{
":immediate: ./immediate",
":immediate: ./immediate",
":immediate: ./immediate",
},
)
},
)
t.Run(
"debounced",
func(t *testing.T) {
_testSilence(
t,
func() {
touch("debounced")
time.Sleep(2 * time.Millisecond)
touch("debounced")
time.Sleep(10 * time.Millisecond)
touch("debounced")
time.Sleep(11 * time.Millisecond)
},
[]string{
":debounced: ./debounced",
":debounced: ./debounced",
},
)
},
)
}

func _testSilence(t *testing.T, modfunc func(), expected []string) {
defer utils.WithTempDir(t)()

// There's some race condition in rjeczalik/notify. If we don't wait a bit
// here, we sometimes receive notifications for the change above even
// though we haven't started the watcher.
time.Sleep(200 * time.Millisecond)

confTxt := `
@shell = bash

immediate {
prep: echo ":immediate:" @mods
}
debounced {
silence: 10ms
prep: echo ":debounced:" @mods
}
`
cnf, err := conf.Parse("test", confTxt)
if err != nil {
t.Fatal(err)
}

lt := termlog.NewLogTest()
modchan := make(chan *moddwatch.Mod, 1024)
cback := func() {
start := time.Now()
modfunc()
for {
ret := events(lt.String())
if reflect.DeepEqual(ret, expected) {
break
}
if time.Since(start) > timeout {
t.Errorf("Expected\n%#v\nGot\n%#v", expected, ret)
break
}
time.Sleep(50 * time.Millisecond)
}
modchan <- nil
}

mr := ModRunner{
Log: lt.Log,
Config: cnf,
}

err = mr.runOnChan(modchan, cback)
if err != nil {
t.Fatalf("runOnChan: %s", err)
}
ret := events(lt.String())
if !reflect.DeepEqual(ret, expected) {
t.Errorf("Expected\n%#v\nGot\n%#v", expected, ret)
}
}