Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add client function to return ping latency #201

Merged
merged 6 commits into from
Sep 27, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
97 changes: 93 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,93 @@ 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

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 > time.Millisecond*3 {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is so flakey... I don't like this at all.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah true but I don't see an alternative, I was getting 0ms locally and it worked consistently, but when actions runs the test it fails. Do you have a better idea?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could try reverting the last 2 commits and using returnedLatency.Truncate instead ofreturnedLatency.Round, but even if that works now I'm not sure it won't eventually cause your tests to fail because actions ran too slowly.

t.Fatalf("Latency %s should be equal to %s", returnedLatency, expectedLatency)
}

}

client.Disconnect()
}

func TestCanAttachToPongMessageCallback(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -2064,7 +2153,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 +2228,7 @@ func TestEmptyCapabilities(t *testing.T) {
name string
in []string
}
var tests = []testTable{
tests := []testTable{
{"nil", nil},
{"Empty list", []string{}},
}
Expand Down