Skip to content

Commit

Permalink
Merge pull request #201 from douglascdev/latency
Browse files Browse the repository at this point in the history
Add client function to return ping latency
  • Loading branch information
gempir authored Sep 27, 2024
2 parents 81d96cf + 2519e47 commit 477f613
Show file tree
Hide file tree
Showing 3 changed files with 132 additions and 4 deletions.
1 change: 1 addition & 0 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
}
}
Expand Down
98 changes: 94 additions & 4 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -2064,7 +2154,7 @@ func TestCapabilities(t *testing.T) {
in []string
expected string
}
var tests = []testTable{
tests := []testTable{
{
"Default Capabilities (not modifying)",
nil,
Expand Down Expand Up @@ -2139,7 +2229,7 @@ func TestEmptyCapabilities(t *testing.T) {
name string
in []string
}
var tests = []testTable{
tests := []testTable{
{"nil", nil},
{"Empty list", []string{}},
}
Expand Down

0 comments on commit 477f613

Please sign in to comment.