From 955d2fa7e718d6714d883253672cffdd99a634a9 Mon Sep 17 00:00:00 2001 From: Stephen Searles Date: Fri, 11 Aug 2017 17:14:36 -0700 Subject: [PATCH] initial commit of the idleshut package --- idleshut.go | 176 +++++++++++++++++++++++++++++++++++++++++++++++ idleshut_test.go | 90 ++++++++++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 idleshut.go create mode 100644 idleshut_test.go diff --git a/idleshut.go b/idleshut.go new file mode 100644 index 0000000..109b655 --- /dev/null +++ b/idleshut.go @@ -0,0 +1,176 @@ +// Package idleshut provides a lifecycle and idletime management tool. +package idleshut + +import ( + "sync" + "time" +) + +// Config are the options for creating a new process +type Config struct { + + // Start is called by Process.Start. If an error is returned, + // starting the process is aborted. + Start func() error + + // Stop is called by Process.Stop. If an error is returned, + // stopping the process is aborted. + Stop func() error + + // IdleTick is called by Process for every iteration of Tick. If + // an error is returned, it is sent to IdleProcessError. IdleTick + // and IdleProcessError must both be set or unset; mixed set-edness + // will result in a panic. + IdleTick func() error + IdleProcessError func(error) + Tick time.Duration + + // MaxIdleTicks, if nonzero and ticking is configured, will cause + // the process to stop once the maximum number of ticks has been + // reached. + MaxIdleTicks uint +} + +func (cfg Config) validate() { + allSet := cfg.IdleTick == nil && cfg.IdleProcessError == nil && cfg.Tick == 0 + noneSet := cfg.IdleTick != nil && cfg.IdleProcessError != nil && cfg.Tick != 0 + + if !allSet && !noneSet { + panic("idleshut: Config.IdleTick, config.IdleProcessError, and config.Tick must all be set or all be unset") + } +} + +// New returns a new Process with the given configuration. +func New(cfg Config) *Process { + + cfg.validate() + return &Process{cfg: cfg} +} + +// Process is a lifecycle and idle process monitor. +type Process struct { + cfg Config + + mtx sync.Mutex + started time.Time + stopped time.Time + running bool + touched bool + idleTicks uint + currentGeneration uint +} + +// Touch resets the idle timer to zero. +func (p *Process) Touch() bool { + p.mtx.Lock() + defer p.mtx.Unlock() + + p.idleTicks = 0 + p.touched = true + + return p.running +} + +// Start initiates the process. +func (p *Process) Start() error { + p.mtx.Lock() + defer p.mtx.Unlock() + + if !p.running { + var err error + if p.cfg.Start != nil { + err = p.cfg.Start() + } + if err != nil { + return err + } + p.running = true + p.started = time.Now() + p.currentGeneration++ + go p.startTicker() + } + + return nil +} + +// Running returns true if the process is currently running. +func (p *Process) Running() bool { + p.mtx.Lock() + defer p.mtx.Unlock() + + return p.running +} + +func (p *Process) generation() uint { + p.mtx.Lock() + defer p.mtx.Unlock() + return p.currentGeneration +} + +func (p *Process) startTicker() { + if p.cfg.Tick == 0 || p.cfg.IdleTick == nil { + return + } + + startingGen := p.generation() + + tk := time.NewTicker(p.cfg.Tick) + for { + <-tk.C + // if we stopped running, or we started again and thus the geneneration has advanced, return + if !p.Running() || p.generation() != startingGen { + return + } + + func() { + p.mtx.Lock() + defer p.mtx.Unlock() + + if p.touched { + p.idleTicks = 0 + p.touched = false //reset + } else { + p.idleTicks++ + if p.cfg.IdleTick != nil { + p.idleProcessError(p.cfg.IdleTick()) + } + if p.idleTicks >= p.cfg.MaxIdleTicks { + p.idleProcessError(p.stopWithLock()) + } + } + }() + } +} + +func (p *Process) idleProcessError(err error) { + if err != nil && p.cfg.IdleProcessError != nil { + go p.cfg.IdleProcessError(err) + } +} + +// Stop halts the process. +func (p *Process) Stop() error { + p.mtx.Lock() + defer p.mtx.Unlock() + + if p.running { + return p.stopWithLock() + } + + return nil +} + +func (p *Process) stopWithLock() error { + var err error + if p.cfg.Stop != nil { + err = p.cfg.Stop() + } + if err != nil { + return err + } + p.idleTicks = 0 + p.running = false + p.stopped = time.Now() + + return nil +} diff --git a/idleshut_test.go b/idleshut_test.go new file mode 100644 index 0000000..85cd526 --- /dev/null +++ b/idleshut_test.go @@ -0,0 +1,90 @@ +package idleshut + +import ( + "sync/atomic" + "testing" + "time" +) + +func TestFlow(t *testing.T) { + p := New(Config{}) + if p.Running() { + t.Fatal("running when first started") + } + + _ = p.Start() + if !p.Running() { + t.Fatal("should be running when started") + } + + _ = p.Stop() + if p.Running() { + t.Fatal("running after stopped") + } +} + +func TestTicking(t *testing.T) { + var idleTicksNonPtr uint32 + var idleTicks = &idleTicksNonPtr + + p := New(Config{ + Tick: 10 * time.Millisecond, + MaxIdleTicks: 10, + IdleTick: func() error { + atomic.AddUint32(idleTicks, 1) + return nil + }, + IdleProcessError: func(err error) { + t.Fatal("got an idle process error:", err) + }, + }) + + // initial start + _ = p.Start() + if !p.Running() { + t.Fatal("should be running when started") + } + + // use up all the ticks right away + time.Sleep(1 * time.Second) + + if atomic.LoadUint32(idleTicks) != 10 { + t.Fatal("should have seen 10 idle ticks, which fits into MaxIdleTicks, saw", idleTicks) + } + if p.Running() { + t.Fatal("should be expired from idle ticks") + } + + // reset the ticks, but dont restart the process + atomic.StoreUint32(idleTicks, 0) + time.Sleep(130 * time.Millisecond) + if atomic.LoadUint32(idleTicks) != 0 { + t.Fatal("should have seen 0 idle ticks with no process running, saw", idleTicks) + } + + _ = p.Start() + if !p.Running() { + t.Fatal("should be running when started again") + } + + time.Sleep(31 * time.Millisecond) + + if saw := atomic.LoadUint32(idleTicks); saw != 3 { + t.Fatal("should have seen 3 idle ticks, saw", saw) + } + + p.Touch() + time.Sleep(110 * time.Millisecond) + + if saw := atomic.LoadUint32(idleTicks); saw != 13 { + t.Fatal("should have seen 13 idle ticks since we touched the process partway through, saw", saw) + } + + // reset the ticks and restart the process + atomic.StoreUint32(idleTicks, 0) + _ = p.Start() + + if g := p.generation(); g != 3 { + t.Fatal("we've started 3 times, but saw we counted to generation", g) + } +}