refactor: simplify OnInterval API to auto-start and return stop func

Replace the exported OnIntervalRoutine struct (Start/Stop/UpdateInterval)
with a single function that auto-starts the goroutine and returns an
idempotent stop closure. Uses close(channel) instead of send-on-channel,
fixing a potential deadlock when the goroutine exits via context disposal.

Closes #5 item 4.
This commit is contained in:
Ryan Hamamura
2026-02-12 12:27:50 -10:00
parent 2310e45d35
commit 532651552a
4 changed files with 39 additions and 79 deletions

View File

@@ -69,7 +69,7 @@ func main() {
- **CSRF protection** — automatic token generation and validation on every action - **CSRF protection** — automatic token generation and validation on every action
- **Rate limiting** — token-bucket algorithm, configurable globally and per-action - **Rate limiting** — token-bucket algorithm, configurable globally and per-action
- **Event handling** — `OnClick`, `OnChange`, `OnSubmit`, `OnInput`, `OnFocus`, `OnBlur`, `OnMouseEnter`, `OnMouseLeave`, `OnScroll`, `OnDblClick`, `OnKeyDown`, and `OnKeyDownMap` for multi-key bindings - **Event handling** — `OnClick`, `OnChange`, `OnSubmit`, `OnInput`, `OnFocus`, `OnBlur`, `OnMouseEnter`, `OnMouseLeave`, `OnScroll`, `OnDblClick`, `OnKeyDown`, and `OnKeyDownMap` for multi-key bindings
- **Timed routines** — `OnInterval` with start/stop/update controls, tied to context lifecycle - **Timed routines** — `OnInterval` auto-starts a ticker goroutine, returns a stop function, tied to context lifecycle
- **Redirects** — `Redirect`, `ReplaceURL`, and format-string variants - **Redirects** — `Redirect`, `ReplaceURL`, and format-string variants
- **Plugin system** — `func(v *V)` hooks for integrating CSS/JS libraries - **Plugin system** — `func(v *V)` hooks for integrating CSS/JS libraries
- **Structured logging** — zerolog with configurable levels; console output in dev, JSON in production - **Structured logging** — zerolog with configurable levels; console output in dev, JSON in production

View File

@@ -131,17 +131,17 @@ func (c *Context) getAction(id string) (actionEntry, error) {
return actionEntry{}, fmt.Errorf("action '%s' not found", id) return actionEntry{}, fmt.Errorf("action '%s' not found", id)
} }
// OnInterval starts a go routine that sets a time.Ticker with the given duration and executes // OnInterval starts a goroutine that executes handler on every tick of the given duration.
// the given handler func() on every tick. Use *Routine.UpdateInterval to update the interval. // The goroutine is tied to the context lifecycle and will stop when the context is disposed.
func (c *Context) OnInterval(duration time.Duration, handler func()) *OnIntervalRoutine { // Returns a func() that stops the interval when called.
func (c *Context) OnInterval(duration time.Duration, handler func()) func() {
var cn chan struct{} var cn chan struct{}
if c.isComponent() { // components use the chan on the parent page ctx if c.isComponent() { // components use the chan on the parent page ctx
cn = c.parentPageCtx.ctxDisposedChan cn = c.parentPageCtx.ctxDisposedChan
} else { } else {
cn = c.ctxDisposedChan cn = c.ctxDisposedChan
} }
r := newOnIntervalRoutine(cn, duration, handler) return newOnInterval(cn, duration, handler)
return r
} }
// Signal creates a reactive signal and initializes it with the given value. // Signal creates a reactive signal and initializes it with the given value.
@@ -379,7 +379,7 @@ func (c *Context) dispose() {
} }
// stopAllRoutines closes ctxDisposedChan, broadcasting to all listening // stopAllRoutines closes ctxDisposedChan, broadcasting to all listening
// goroutines (OnIntervalRoutine, SSE loop) that this context is done. // goroutines (OnInterval, SSE loop) that this context is done.
func (c *Context) stopAllRoutines() { func (c *Context) stopAllRoutines() {
select { select {
case <-c.ctxDisposedChan: case <-c.ctxDisposedChan:

View File

@@ -37,7 +37,9 @@ func main() {
return 1000 / time.Duration(refreshRate.Int()) * time.Millisecond return 1000 / time.Duration(refreshRate.Int()) * time.Millisecond
} }
updateData := c.OnInterval(computedTickDuration(), func() { var stopUpdate func()
startInterval := func() {
stopUpdate = c.OnInterval(computedTickDuration(), func() {
ts := time.Now().UnixMilli() ts := time.Now().UnixMilli()
val := rand.ExpFloat64() * 10 val := rand.ExpFloat64() * 10
@@ -48,18 +50,20 @@ func main() {
}; };
`, ts, val)) `, ts, val))
}) })
updateData.Start() }
startInterval()
updateRefreshRate := c.Action(func() { updateRefreshRate := c.Action(func() {
updateData.UpdateInterval(computedTickDuration()) stopUpdate()
startInterval()
}) })
toggleIsLive := c.Action(func() { toggleIsLive := c.Action(func() {
isLive = isLiveSig.Bool() isLive = isLiveSig.Bool()
if isLive { if isLive {
updateData.Start() startInterval()
} else { } else {
updateData.Stop() stopUpdate()
} }
}) })
c.View(func() h.H { c.View(func() h.H {

View File

@@ -1,76 +1,32 @@
package via package via
import ( import (
"sync"
"sync/atomic" "sync/atomic"
"time" "time"
) )
// OnIntervalRoutine allows for defining concurrent goroutines safely. Goroutines started by *OnIntervalRoutine func newOnInterval(ctxDisposedChan chan struct{}, duration time.Duration, handler func()) func() {
// are tied to the *Context lifecycle. localInterrupt := make(chan struct{})
type OnIntervalRoutine struct { var stopped atomic.Bool
mu sync.RWMutex
ctxDisposed chan struct{}
localInterrupt chan struct{}
isRunning atomic.Bool
routineFn func()
tckDuration time.Duration
updateTkrChan chan time.Duration
}
// UpdateInterval sets a new interval duration for the internal *time.Ticker. If the provided go func() {
// duration is equal of less than 0, UpdateInterval does nothing. tkr := time.NewTicker(duration)
func (r *OnIntervalRoutine) UpdateInterval(d time.Duration) { defer tkr.Stop()
r.mu.Lock()
defer r.mu.Unlock()
r.tckDuration = d
r.updateTkrChan <- d
}
// Start executes the predifined goroutine. If no predifined goroutine exists, or it already
// started, Start does nothing.
func (r *OnIntervalRoutine) Start() {
if !r.isRunning.CompareAndSwap(false, true) || r.routineFn == nil {
return
}
go r.routineFn()
}
// Stop interrupts the predifined goroutine. If no predifined goroutine exists, or it already
// ustopped, Stop does nothing.
func (r *OnIntervalRoutine) Stop() {
if !r.isRunning.CompareAndSwap(true, false) || r.routineFn == nil {
return
}
r.localInterrupt <- struct{}{}
}
func newOnIntervalRoutine(ctxDisposedChan chan struct{},
duration time.Duration, handler func()) *OnIntervalRoutine {
r := &OnIntervalRoutine{
ctxDisposed: ctxDisposedChan,
localInterrupt: make(chan struct{}),
updateTkrChan: make(chan time.Duration),
}
r.tckDuration = duration
r.routineFn = func() {
r.mu.RLock()
tkr := time.NewTicker(r.tckDuration)
r.mu.RUnlock()
defer tkr.Stop() // clean up the ticker when routine stops
for { for {
select { select {
case <-r.ctxDisposed: // dispose of the routine when ctx is disposed case <-ctxDisposedChan:
return return
case <-r.localInterrupt: // dispose of the routine on interrupt signal case <-localInterrupt:
return return
case d := <-r.updateTkrChan:
tkr.Reset(d)
case <-tkr.C: case <-tkr.C:
handler() handler()
} }
} }
}()
return func() {
if stopped.CompareAndSwap(false, true) {
close(localInterrupt)
}
} }
return r
} }