diff --git a/README.MD b/README.MD index 8a5f972..90ad939 100644 --- a/README.MD +++ b/README.MD @@ -208,6 +208,7 @@ func (c *Client) Depart(channel string) func (c *Client) Userlist(channel string) ([]string, error) func (c *Client) Connect() error func (c *Client) Disconnect() error +func (c *Client) Latency() (latency time.Duration, err error) ``` ### Options diff --git a/client.go b/client.go index 11c48a4..9e0b784 100644 --- a/client.go +++ b/client.go @@ -378,6 +378,7 @@ type Client struct { channelUserlistMutex *sync.RWMutex channelUserlist map[string]map[string]bool channelsMtx *sync.RWMutex + latencyMutex *sync.RWMutex onConnect func() onWhisperMessage func(message WhisperMessage) onPrivateMessage func(message PrivateMessage) @@ -430,6 +431,13 @@ type Client struct { // The variable may only be modified before calling Connect PongTimeout time.Duration + // LastSentPing is the time the last ping was sent. Used to measure latency. + lastSentPing time.Time + + // Latency is the latency to the irc server measured as the duration + // between when the last ping was sent and when the last pong was received + latency time.Duration + // SetupCmd is the command that is ran on successful connection to Twitch. Useful if you are proxying or something to run a custom command on connect. // The variable must be modified before calling Connect or the command will not run. SetupCmd string @@ -452,6 +460,7 @@ func NewClient(username, oauth string) *Client { channels: map[string]bool{}, channelUserlist: map[string]map[string]bool{}, channelsMtx: &sync.RWMutex{}, + latencyMutex: &sync.RWMutex{}, messageReceived: make(chan bool), read: make(chan string, ReadBufferSize), @@ -616,6 +625,23 @@ func (c *Client) Join(channels ...string) { c.channelsMtx.Unlock() } +// Latency returns the latency to the irc server measured as the duration +// between when the last ping was sent and when the last pong was received. +// Returns zero duration if no ping has been sent yet. +// Returns an error if SendPings is false. +func (c *Client) Latency() (latency time.Duration, err error) { + if !c.SendPings { + err = errors.New("measuring latency requires SendPings to be true") + return + } + + c.latencyMutex.RLock() + defer c.latencyMutex.RUnlock() + + latency = c.latency + return +} + // Creates an irc join message to join the given channels. // // Returns the join message, any channels included in the join message, @@ -862,6 +888,14 @@ func (c *Client) startPinger(closer io.Closer, wg *sync.WaitGroup) { } c.send(pingMessage) + // update lastSentPing without blocking this goroutine waiting for the lock + go func() { + timeSent := time.Now() + c.latencyMutex.Lock() + c.lastSentPing = timeSent + c.latencyMutex.Unlock() + }() + select { case <-c.pongReceived: // Received pong message within the time limit, we're good @@ -1157,6 +1191,9 @@ func (c *Client) handlePongMessage(msg PongMessage) { // Received a pong that was sent by us select { case c.pongReceived <- true: + c.latencyMutex.Lock() + c.latency = time.Since(c.lastSentPing) + c.latencyMutex.Unlock() default: } } diff --git a/client_test.go b/client_test.go index 8208483..3d24385 100644 --- a/client_test.go +++ b/client_test.go @@ -17,8 +17,10 @@ import ( "time" ) -var startPortMutex sync.Mutex -var startPort = 10000 +var ( + startPortMutex sync.Mutex + startPort = 10000 +) func newPort() (r int) { startPortMutex.Lock() @@ -1846,6 +1848,94 @@ func TestPinger(t *testing.T) { client.Disconnect() } +func TestLatencySendPingsFalse(t *testing.T) { + t.Parallel() + client := newTestClient("") + client.SendPings = false + if _, err := client.Latency(); err == nil { + t.Fatal("Should not be able to measure latency when SendPings is false") + } +} + +func TestLatencyBeforePings(t *testing.T) { + t.Parallel() + var ( + client *Client + latency time.Duration + err error + ) + client = newTestClient("") + if latency, err = client.Latency(); err != nil { + t.Fatal(fmt.Errorf("Failed to measure latency: %w", err)) + } + + if latency != 0 { + t.Fatal("Latency should be zero before a ping is sent") + } +} + +func TestLatency(t *testing.T) { + t.Parallel() + const idlePingInterval = 10 * time.Millisecond + const expectedLatency = 50 * time.Millisecond + const toleranceLatency = 5 * time.Millisecond + + wait := make(chan bool) + + var conn net.Conn + + host := startServer(t, func(c net.Conn) { + conn = c + }, func(message string) { + if message == pingMessage { + // Send an emulated pong + <-time.After(expectedLatency) + wait <- true + fmt.Fprintf(conn, formatPong(strings.Split(message, " :")[1])+"\r\n") + } + }) + client := newTestClient(host) + client.IdlePingInterval = idlePingInterval + + go client.Connect() + + select { + case <-wait: + case <-time.After(time.Second * 3): + t.Fatal("Did not establish a connection") + } + + var ( + returnedLatency time.Duration + err error + ) + for i := 0; i < 5; i++ { + // Wait for the client to send a ping + <-time.After(idlePingInterval + time.Millisecond*10) + + if returnedLatency, err = client.Latency(); err != nil { + t.Fatal(fmt.Errorf("Failed to measure latency: %w", err)) + } + + returnedLatency = returnedLatency.Round(time.Millisecond) + + latencyDiff := func() time.Duration { + diff := returnedLatency - expectedLatency + if diff < 0 { + return -diff + } + return diff + }() + + if latencyDiff > toleranceLatency { + t.Fatalf("Latency %s should be within 3ms of %s", returnedLatency, expectedLatency) + } + + } + + client.Disconnect() +} + func TestCanAttachToPongMessageCallback(t *testing.T) { t.Parallel() @@ -2064,7 +2154,7 @@ func TestCapabilities(t *testing.T) { in []string expected string } - var tests = []testTable{ + tests := []testTable{ { "Default Capabilities (not modifying)", nil, @@ -2139,7 +2229,7 @@ func TestEmptyCapabilities(t *testing.T) { name string in []string } - var tests = []testTable{ + tests := []testTable{ {"nil", nil}, {"Empty list", []string{}}, }