3
0
mirror of https://github.com/ergochat/ergo.git synced 2024-11-26 05:49:25 +01:00
ergo/irc/idletimer.go
Shivaram Lingamneni ad1e00629b fix a race condition in idle timeouts
squigz on freenode reported an issue where bots were responding to PING
on time, but were occasionally being timed out regardless. This was a race
condition: timeout was detected as idleTime >= it.quitTimeout, but if
the client responded promptly to its PING message and sent no further messages,
but the main loop subsequently slept for longer than expected (i.e., significantly
longer than quitTimeout), this condition would be met through no fault of the
client's.

The fix here is to explicitly track the last time the ping was sent, then test
!lastSeen.After(lastPinged) instead (making use of time.Time's monotonicity).
It is sufficient that the measurement of lastPinged happens-before the PING is sent.
2017-12-02 20:30:26 -05:00

158 lines
3.6 KiB
Go

// Copyright (c) 2017 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// released under the MIT license
package irc
import (
"fmt"
"sync"
"time"
)
const (
// RegisterTimeout is how long clients have to register before we disconnect them
RegisterTimeout = time.Minute
// IdleTimeout is how long without traffic before a registered client is considered idle.
IdleTimeout = time.Minute + time.Second*30
// QuitTimeout is how long without traffic before an idle client is disconnected
QuitTimeout = time.Minute
)
// client idleness state machine
type TimerState uint
const (
TimerUnregistered TimerState = iota // client is unregistered
TimerActive // client is actively sending commands
TimerIdle // client is idle, we sent PING and are waiting for PONG
)
type IdleTimer struct {
sync.Mutex // tier 1
// immutable after construction
registerTimeout time.Duration
idleTimeout time.Duration
quitTimeout time.Duration
// mutable
client *Client
lastSeen time.Time
}
// NewIdleTimer sets up a new IdleTimer using constant timeouts.
func NewIdleTimer(client *Client) *IdleTimer {
it := IdleTimer{
registerTimeout: RegisterTimeout,
idleTimeout: IdleTimeout,
quitTimeout: QuitTimeout,
client: client,
}
return &it
}
// Start starts counting idle time; if there is no activity from the client,
// it will eventually be stopped.
func (it *IdleTimer) Start() {
it.Lock()
it.lastSeen = time.Now()
it.Unlock()
go it.mainLoop()
}
func (it *IdleTimer) mainLoop() {
state := TimerUnregistered
var lastPinged time.Time
for {
it.Lock()
client := it.client
lastSeen := it.lastSeen
it.Unlock()
if client == nil {
return
}
now := time.Now()
idleTime := now.Sub(lastSeen)
var nextSleep time.Duration
if state == TimerUnregistered {
if client.Registered() {
// transition to active, process new deadlines below
state = TimerActive
} else {
nextSleep = it.registerTimeout - idleTime
}
} else if state == TimerIdle {
if lastSeen.After(lastPinged) {
// new pong came in after we transitioned to TimerIdle,
// transition back to active and process deadlines below
state = TimerActive
} else {
nextSleep = 0
}
}
if state == TimerActive {
nextSleep = it.idleTimeout - idleTime
if nextSleep <= 0 {
state = TimerIdle
lastPinged = now
client.Ping()
// grant the client at least quitTimeout to respond
nextSleep = it.quitTimeout
}
}
if nextSleep <= 0 {
// ran out of time, hang them up
client.Quit(it.quitMessage(state))
client.destroy()
return
}
time.Sleep(nextSleep)
}
}
// Touch registers activity (e.g., sending a command) from an client.
func (it *IdleTimer) Touch() {
it.Lock()
client := it.client
it.Unlock()
// ignore touches for unregistered clients
if client != nil && !client.Registered() {
return
}
it.Lock()
it.lastSeen = time.Now()
it.Unlock()
}
// Stop stops counting idle time.
func (it *IdleTimer) Stop() {
it.Lock()
defer it.Unlock()
// no need to stop the goroutine, it'll clean itself up in a few minutes;
// just ensure the Client object is collectable
it.client = nil
}
func (it *IdleTimer) quitMessage(state TimerState) string {
switch state {
case TimerUnregistered:
return fmt.Sprintf("Registration timeout: %v", it.registerTimeout)
case TimerIdle:
// how many seconds before registered clients are timed out (IdleTimeout plus QuitTimeout).
return fmt.Sprintf("Ping timeout: %v", (it.idleTimeout + it.quitTimeout))
default:
// shouldn't happen
return ""
}
}