diff --git a/conf/conf.go b/conf/conf.go index 6142f89..6fc9c0f 100644 --- a/conf/conf.go +++ b/conf/conf.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "sort" + "time" ) // A Daemon is a persistent process that is kept running @@ -27,6 +28,7 @@ type Block struct { Daemons []Daemon Preps []Prep + Silence *Silence } func (b *Block) addPrep(command string, options []string) error { @@ -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 diff --git a/conf/lex.go b/conf/lex.go index 22ce2a7..8329b5e 100644 --- a/conf/lex.go +++ b/conf/lex.go @@ -35,6 +35,7 @@ const ( itemQuotedString itemPrep itemRightParen + itemSilence itemSpace itemVarName itemEquals @@ -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: @@ -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 diff --git a/conf/lex_test.go b/conf/lex_test.go index 7f9bcfa..68d6f6d 100644 --- a/conf/lex_test.go +++ b/conf/lex_test.go @@ -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) { diff --git a/conf/parse.go b/conf/parse.go index 214165c..31c5682 100644 --- a/conf/parse.go +++ b/conf/parse.go @@ -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: diff --git a/conf/silence.go b/conf/silence.go new file mode 100644 index 0000000..e098279 --- /dev/null +++ b/conf/silence.go @@ -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 "" + } + return fmt.Sprintf("", 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 +} diff --git a/daemon.go b/daemon.go index 5f86c7f..fb9bb3c 100644 --- a/daemon.go +++ b/daemon.go @@ -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 diff --git a/modd.go b/modd.go index ebc3063..e75c35b 100644 --- a/modd.go +++ b/modd.go @@ -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 diff --git a/modd_test.go b/modd_test.go index 46e5453..3072764 100644 --- a/modd_test.go +++ b/modd_test.go @@ -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 } @@ -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) + } +}