Files
goclaw/internal/channels/typing/controller.go
T
viettranx 043149bd43 ci: add CI workflow, Makefile targets, and fix typing keepalive race (#41)
- Add GitHub Actions CI with parallel Go (build/test -race/vet) and Web UI (pnpm build) jobs
- Add Makefile targets: test, vet, check-web, setup, ci
- Fix data race in typing keepalive: remove nil assignment after close(keepaliveDone)
  so the goroutine can safely read the channel without holding the mutex

Co-Authored-By: Duc Nguyen <me@vanducng.dev>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:22:30 +07:00

204 lines
4.9 KiB
Go

// Package typing provides a channel-agnostic typing indicator controller
// with TTL safety net, keepalive support, and post-close guards.
//
// The controller prevents stuck typing indicators by:
// - Auto-stopping after a configurable TTL (default 60s)
// - Requiring both MarkRunComplete + MarkDispatchIdle for graceful cleanup
// - Guarding against post-close keepalive invocations
package typing
import (
"log/slog"
"sync"
"time"
)
// Options configures a typing indicator controller.
type Options struct {
// MaxDuration is the TTL safety net. If the indicator hasn't been
// stopped after this duration, it auto-stops. 0 disables the TTL.
// Default: 60s.
MaxDuration time.Duration
// KeepaliveInterval is how often to re-send the typing action.
// Telegram typing expires after 5s, Discord after 10s.
// 0 disables keepalive (single fire-and-forget).
KeepaliveInterval time.Duration
// StartFn sends the channel-specific typing indicator.
// Called on Start() and on each keepalive tick.
StartFn func() error
// StopFn sends the channel-specific stop-typing signal.
// Optional — some channels (Telegram) auto-stop on message send.
StopFn func() error
}
// Controller manages the lifecycle of a typing indicator.
// It is safe for concurrent use.
type Controller struct {
mu sync.Mutex
// State flags
closed bool // post-close guard: prevents stale startFn calls
runComplete bool // signal 1: agent finished processing
dispatchIdle bool // signal 2: message delivery finished
stopSent bool // prevents duplicate stopFn calls
// Configuration
maxDuration time.Duration
keepaliveInterval time.Duration
startFn func() error
stopFn func() error
// Timers
ttlTimer *time.Timer
keepaliveDone chan struct{}
}
// New creates a typing controller with the given options.
func New(opts Options) *Controller {
maxDur := opts.MaxDuration
if maxDur == 0 {
maxDur = 60 * time.Second
}
return &Controller{
maxDuration: maxDur,
keepaliveInterval: opts.KeepaliveInterval,
startFn: opts.StartFn,
stopFn: opts.StopFn,
}
}
// Start begins the typing indicator, TTL timer, and keepalive loop.
func (c *Controller) Start() {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return
}
// Fire initial typing action
c.fireStart()
// Start TTL safety net
if c.maxDuration > 0 {
c.ttlTimer = time.AfterFunc(c.maxDuration, func() {
c.mu.Lock()
if !c.closed {
slog.Debug("typing: TTL exceeded, auto-stopping", "ttl", c.maxDuration)
c.forceStop()
}
c.mu.Unlock()
})
}
// Start keepalive loop
if c.keepaliveInterval > 0 {
c.keepaliveDone = make(chan struct{})
go c.keepaliveLoop()
}
}
// Stop forcefully stops the typing indicator and all timers.
// Safe to call multiple times (idempotent).
func (c *Controller) Stop() {
c.mu.Lock()
defer c.mu.Unlock()
c.forceStop()
}
// MarkRunComplete signals that the agent has finished processing.
// Cleanup happens only when both MarkRunComplete and MarkDispatchIdle have been called.
func (c *Controller) MarkRunComplete() {
c.mu.Lock()
defer c.mu.Unlock()
c.runComplete = true
c.tryCleanup()
}
// MarkDispatchIdle signals that message delivery has completed.
// Cleanup happens only when both MarkRunComplete and MarkDispatchIdle have been called.
func (c *Controller) MarkDispatchIdle() {
c.mu.Lock()
defer c.mu.Unlock()
c.dispatchIdle = true
c.tryCleanup()
}
// tryCleanup runs cleanup only when both completion signals have been received.
// Must be called with c.mu held.
func (c *Controller) tryCleanup() {
if c.runComplete && c.dispatchIdle && !c.closed {
c.forceStop()
}
}
// forceStop cancels all timers and sends stop signal.
// Must be called with c.mu held.
func (c *Controller) forceStop() {
if c.closed {
return
}
c.closed = true
// Cancel TTL timer
if c.ttlTimer != nil {
c.ttlTimer.Stop()
c.ttlTimer = nil
}
// Stop keepalive loop
if c.keepaliveDone != nil {
close(c.keepaliveDone)
}
// Send stop signal
c.fireStop()
}
// fireStart invokes the channel-specific start function.
// Must be called with c.mu held.
func (c *Controller) fireStart() {
if c.closed || c.startFn == nil {
return
}
if err := c.startFn(); err != nil {
slog.Debug("typing: startFn error", "error", err)
}
}
// fireStop invokes the channel-specific stop function (once).
// Must be called with c.mu held.
func (c *Controller) fireStop() {
if c.stopSent || c.stopFn == nil {
return
}
c.stopSent = true
if err := c.stopFn(); err != nil {
slog.Debug("typing: stopFn error", "error", err)
}
}
// keepaliveLoop periodically re-sends the typing indicator.
func (c *Controller) keepaliveLoop() {
ticker := time.NewTicker(c.keepaliveInterval)
defer ticker.Stop()
for {
select {
case <-c.keepaliveDone:
return
case <-ticker.C:
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return
}
c.fireStart()
c.mu.Unlock()
}
}
}