fix: clean up leaked contexts on SSE disconnect and add orphan reaper

When clients disconnect without beforeunload firing (network drops,
mobile kills, crashes), contexts leaked in the registry permanently.

- Extract cleanupCtx helper for dispose/unregister sequence
- Call cleanupCtx on SSE disconnect (sse.Context().Done())
- Add background reaper for contexts where SSE never connected
- Add ContextTTL config option (default 30s, negative disables)
- Fix inverted condition in devModeRemovePersisted
This commit is contained in:
Ryan Hamamura
2026-02-06 10:34:28 -10:00
parent 2c44671d0e
commit 6dcd54c88b
4 changed files with 184 additions and 23 deletions

102
via.go
View File

@@ -36,20 +36,21 @@ var datastarJS []byte
// V is the root application.
// It manages page routing, user sessions, and SSE connections for live updates.
type V struct {
cfg Options
mux *http.ServeMux
server *http.Server
logger zerolog.Logger
contextRegistry map[string]*Context
contextRegistryMutex sync.RWMutex
documentHeadIncludes []h.H
documentFootIncludes []h.H
devModePageInitFnMap map[string]func(*Context)
sessionManager *scs.SessionManager
pubsub PubSub
datastarPath string
datastarContent []byte
datastarOnce sync.Once
cfg Options
mux *http.ServeMux
server *http.Server
logger zerolog.Logger
contextRegistry map[string]*Context
contextRegistryMutex sync.RWMutex
documentHeadIncludes []h.H
documentFootIncludes []h.H
devModePageInitFnMap map[string]func(*Context)
sessionManager *scs.SessionManager
pubsub PubSub
datastarPath string
datastarContent []byte
datastarOnce sync.Once
reaperStop chan struct{}
}
func (v *V) logEvent(evt *zerolog.Event, c *Context) *zerolog.Event {
@@ -127,6 +128,9 @@ func (v *V) Config(cfg Options) {
if cfg.PubSub != nil {
v.pubsub = cfg.PubSub
}
if cfg.ContextTTL != 0 {
v.cfg.ContextTTL = cfg.ContextTTL
}
}
// AppendToHead appends the given h.H nodes to the head of the base HTML document.
@@ -238,6 +242,14 @@ func (v *V) currSessionNum() int {
return len(v.contextRegistry)
}
func (v *V) cleanupCtx(c *Context) {
c.dispose()
if v.cfg.DevMode {
v.devModeRemovePersisted(c)
}
v.unregisterCtx(c)
}
func (v *V) unregisterCtx(c *Context) {
if c.id == "" {
v.logErr(c, "unregister ctx failed: ctx contains empty id")
@@ -259,6 +271,50 @@ func (v *V) getCtx(id string) (*Context, error) {
return nil, fmt.Errorf("ctx '%s' not found", id)
}
func (v *V) startReaper() {
ttl := v.cfg.ContextTTL
if ttl < 0 {
return
}
if ttl == 0 {
ttl = 30 * time.Second
}
interval := ttl / 3
if interval < 5*time.Second {
interval = 5 * time.Second
}
v.reaperStop = make(chan struct{})
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-v.reaperStop:
return
case <-ticker.C:
v.reapOrphanedContexts(ttl)
}
}
}()
}
func (v *V) reapOrphanedContexts(ttl time.Duration) {
now := time.Now()
v.contextRegistryMutex.RLock()
var orphans []*Context
for _, c := range v.contextRegistry {
if !c.sseConnected.Load() && now.Sub(c.createdAt) > ttl {
orphans = append(orphans, c)
}
}
v.contextRegistryMutex.RUnlock()
for _, c := range orphans {
v.logInfo(c, "reaping orphaned context (no SSE connection after %s)", ttl)
v.cleanupCtx(c)
}
}
// Start starts the Via HTTP server and blocks until a SIGINT or SIGTERM
// signal is received, then performs a graceful shutdown.
func (v *V) Start() {
@@ -271,6 +327,8 @@ func (v *V) Start() {
Handler: handler,
}
v.startReaper()
errCh := make(chan error, 1)
go func() {
errCh <- v.server.ListenAndServe()
@@ -301,6 +359,9 @@ func (v *V) Shutdown() {
}
func (v *V) shutdown() {
if v.reaperStop != nil {
close(v.reaperStop)
}
v.logInfo(nil, "draining all contexts")
v.drainAllContexts()
@@ -400,10 +461,7 @@ func (v *V) devModeRemovePersisted(c *Context) {
}
file.Close()
// remove ctx to persisted list
if _, ok := ctxRegMap[c.id]; !ok {
delete(ctxRegMap, c.id)
}
delete(ctxRegMap, c.id)
// write persisted list to file
file, err = os.Create(p)
@@ -507,6 +565,7 @@ func New() *V {
// use last-event-id to tell if request is a sse reconnect
sse.Send(datastar.EventTypePatchElements, []string{}, datastar.WithSSEEventId("via"))
c.sseConnected.Store(true)
v.logDebug(c, "SSE connection established")
go func() {
@@ -517,6 +576,7 @@ func New() *V {
select {
case <-sse.Context().Done():
v.logDebug(c, "SSE connection ended")
v.cleanupCtx(c)
return
case <-c.ctxDisposedChan:
v.logDebug(c, "context disposed, closing SSE")
@@ -603,12 +663,8 @@ func New() *V {
v.logErr(c, "failed to handle session close: %v", err)
return
}
c.dispose()
v.logDebug(c, "session close event triggered")
if v.cfg.DevMode {
v.devModeRemovePersisted(c)
}
v.unregisterCtx(c)
v.cleanupCtx(c)
})
return v
}