diff --git a/README.md b/README.md index 82f79db7..aa73b994 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ The following documentation is available: * [Installation](#installation) - [Supported environment variables](#supported-environment-variables) - [Unix Domain Sockets Client](#unix-domain-sockets-client) + - [Vsock Client (experimental)](#vsock-client-experimental) * [Usage](#usage) - [Metrics](#metrics) - [Events](#events) @@ -85,12 +86,13 @@ Find a list of all the available options for your DogStatsD Client in the [Datad ### Supported environment variables * If the `addr` parameter is empty, the client will: - * First use the `DD_DOGSTATSD_URL` environment variables to build a target address. This must be a URL that start with either `udp://` (to connect using UDP) or with `unix://` (to use a Unix Domain Socket). + * First use the `DD_DOGSTATSD_URL` environment variables to build a target address. This must be a URL that start with either `udp://` (to connect using UDP), with `unix://` (to use a Unix Domain Socket) or with `vsock://` (to use a vsock socket). Example for UDP url: `DD_DOGSTATSD_URL=udp://localhost:8125` Example for UDS: `DD_DOGSTATSD_URL=unix:///var/run/datadog/dsd.socket` + Example for vsock: `DD_DOGSTATSD_URL=vsock://host:8125` Example for Windows named pipe`DD_AGENT_HOST=\\.\pipe\my_windows_pipe` * Fallback to the `DD_AGENT_HOST` environment variables to build a target address. - Example: `DD_AGENT_HOST=127.0.0.1:8125` for UDP, `DD_AGENT_HOST=unix:///path/to/socket` for UDS and `DD_AGENT_HOST=\\.\pipe\my_windows_pipe` for Windows named pipe. + Example: `DD_AGENT_HOST=127.0.0.1:8125` for UDP, `DD_AGENT_HOST=unix:///path/to/socket` for UDS, `DD_AGENT_HOST=vsock://host:8125` for vsock and `DD_AGENT_HOST=\\.\pipe\my_windows_pipe` for Windows named pipe. * If `DD_AGENT_HOST` has no port it will default the port to `8125` * You can use `DD_AGENT_PORT` to set the port if `DD_AGENT_HOST` does not have a port set for UDP Example: `DD_AGENT_HOST=127.0.0.1` and `DD_AGENT_PORT=1234` will create a UDP connection to `127.0.0.1:1234`. @@ -112,6 +114,27 @@ env: Agent v6+ accepts packets through a Unix Socket datagram connection. Details about the advantages of using UDS over UDP are available in the [DogStatsD Unix Socket documentation](https://docs.datadoghq.com/developers/dogstatsd/unix_socket/). You can use this protocol by giving a `unix:///path/to/dsd.socket` address argument to the `New` constructor. +### Vsock Client (experimental) + +VM Sockets (vsock) are a Linux-only transport available for allowing hypervisors and guest virtual machines +to communicate with each other in a fast and secure way, similar to Unix Domain Sockets. + +You can use this protocol, on Linux only, by giving a `vsock://:` address argument to the +`New` constructor, where `` is either a context ID or one of the following shorthands: + +| Shorthand | Context ID | Destination | +|--------------|------------|------------------------------------------| +| `hypervisor` | 0 | The hypervisor process | +| `local` | 1 | The local machine, for loopback purposes | +| `host` | 2 | Any process running on the host | + +For example, `vsock://host:8125` sends to port `8125` of the host running the virtual machine. Like +Unix Domain Socket streams, payloads are prefixed with their length so that the Agent can tell them +apart. Other CIDs can be passed in their raw numerical form, which is required for non-standard CIDs, +such as those utilized by [AWS Nitro Enclaves](https://docs.aws.amazon.com/enclaves/latest/user/nitro-enclave-concepts.html#term-socket). + +This feature is experimental, and depends on experimental support in the Agent. + ## Usage In order to use DogStatsD metrics, events, and Service Checks, the Agent must be [running and available](https://docs.datadoghq.com/developers/dogstatsd/?code-lang=go). diff --git a/go.mod b/go.mod index 86c1d1e5..a8333d75 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/golang/mock v1.6.0 github.com/stretchr/testify v1.8.1 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 + golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 ) replace github.com/sirupsen/logrus v1.7.0 => github.com/sirupsen/logrus v1.9.3 diff --git a/statsd/conn.go b/statsd/conn.go new file mode 100644 index 00000000..8960ce54 --- /dev/null +++ b/statsd/conn.go @@ -0,0 +1,189 @@ +//go:build !windows +// +build !windows + +package statsd + +import ( + "encoding/binary" + "net" + "strings" + "sync" + "time" +) + +// connDialer establishes the connections used by a connWriter. +type connDialer interface { + // dial connects to the Agent, giving up after connectTimeout. + dial(connectTimeout time.Duration) (net.Conn, error) + + // transportName returns the name of the transport. It can depend on the connection that + // dial ultimately established, as UDS guesses between datagram and stream sockets. + transportName() string +} + +// connWriter is an internal class wrapping around management of a connection to the Agent. The +// connection is established on the first write and re-established whenever the Agent disconnects. +type connWriter struct { + // Dialer used to establish new connections + dialer connDialer + // Established connection object, or nil if not connected yet + conn net.Conn + // write timeout + writeTimeout time.Duration + // connect timeout + connectTimeout time.Duration + sync.RWMutex // used to lock conn / writer can replace it +} + +// newConnWriter returns a pointer to a new connWriter using the given dialer. +func newConnWriter(dialer connDialer, writeTimeout time.Duration, connectTimeout time.Duration) *connWriter { + // Defer connection to first Write + return &connWriter{dialer: dialer, conn: nil, writeTimeout: writeTimeout, connectTimeout: connectTimeout} +} + +// GetTransportName returns the transport used by the writer +func (w *connWriter) GetTransportName() string { + w.RLock() + defer w.RUnlock() + + return w.dialer.transportName() +} + +// isStreamConn reports whether conn needs length-delimited framing: datagram transports preserve +// message boundaries, stream transports do not. +func isStreamConn(conn net.Conn) bool { + return conn.LocalAddr().Network() != "unixgram" +} + +func (w *connWriter) shouldCloseConnection(err error, partialWrite bool) bool { + if err != nil && partialWrite { + // We can't recover from a partial write + return true + } + if err, isNetworkErr := err.(net.Error); err != nil && (!isNetworkErr || !err.Timeout()) { + // Statsd server disconnected, retry connecting at next packet + return true + } + return false +} + +// Write data to the connection with write timeout and minimal error handling: +// create the connection if nil, and destroy it if the statsd server has disconnected +func (w *connWriter) Write(data []byte) (int, error) { + var n int + partialWrite := false + conn, err := w.ensureConnection() + if err != nil { + return 0, err + } + stream := isStreamConn(conn) + + // When using streams the deadline will only make us drop the packet if we can't write it at all, + // once we've started writing we need to finish. + conn.SetWriteDeadline(time.Now().Add(w.writeTimeout)) + + // When using streams, we append the length of the packet to the data + if stream { + bs := []byte{0, 0, 0, 0} + binary.LittleEndian.PutUint32(bs, uint32(len(data))) + _, err = conn.Write(bs) + + partialWrite = true + + // W need to be able to finish to write partially written packets once we have started. + // But we will reset the connection if we can't write anything at all for a long time. + conn.SetWriteDeadline(time.Now().Add(w.connectTimeout)) + + // Continue writing only if we've written the length of the packet + if err == nil { + n, err = conn.Write(data) + if err == nil { + partialWrite = false + } + } + } else { + n, err = conn.Write(data) + } + + if w.shouldCloseConnection(err, partialWrite) { + w.unsetConnection() + } + return n, err +} + +func (w *connWriter) Close() error { + if w.conn != nil { + return w.conn.Close() + } + return nil +} + +func (w *connWriter) ensureConnection() (net.Conn, error) { + // Check if we've already got a socket we can use + w.RLock() + currentConn := w.conn + w.RUnlock() + + if currentConn != nil { + return currentConn, nil + } + + // Looks like we might need to connect - try again with write locking. + w.Lock() + defer w.Unlock() + if w.conn != nil { + return w.conn, nil + } + + newConn, err := w.dialer.dial(w.connectTimeout) + if err != nil { + return nil, err + } + w.conn = newConn + return newConn, nil +} + +func (w *connWriter) unsetConnection() { + w.Lock() + defer w.Unlock() + _ = w.conn.Close() + w.conn = nil +} + +// isConnectionRefused reports whether err means that nothing is listening on the other end. The +// error message is matched, rather than the error itself, because errors.Is is not available in the +// oldest versions of Go this library supports. +func isConnectionRefused(err error) bool { + return strings.HasSuffix(err.Error(), "connection refused") +} + +// dialWithRetry calls dial until it succeeds, the connect timeout expires, or dial fails with an +// error that isRetryable rejects. Errors meaning that nothing is listening are worth retrying: it's +// likely that the Agent is restarting in that case, and that it will be back shortly. +func dialWithRetry(connectTimeout time.Duration, isRetryable func(error) bool, dial func(timeout time.Duration) (net.Conn, error)) (net.Conn, error) { + connectAttemptsLeft := 3 + connectDeadline := time.Now().Add(connectTimeout) + + // Calculate the backoff time for connection refused errors, but don't exceed one second: this means we won't waste + // longer than 1 seconds worth of time if the socket becomes available immediately after our last connect attempt + connRefusedBackoff := connectTimeout / time.Duration(connectAttemptsLeft+1) + if connRefusedBackoff > time.Second { + connRefusedBackoff = time.Second + } + + for { + connectAttemptsLeft-- + + perCallTimeout := time.Until(connectDeadline) + newConn, err := dial(perCallTimeout) + if err != nil { + if isRetryable(err) && connectAttemptsLeft > 0 { + // If we get a retryable error, we need to wait a bit before trying again. + time.Sleep(connRefusedBackoff) + continue + } + return nil, err + } + return newConn, nil + } +} diff --git a/statsd/conn_test.go b/statsd/conn_test.go new file mode 100644 index 00000000..b2f7e0e7 --- /dev/null +++ b/statsd/conn_test.go @@ -0,0 +1,198 @@ +//go:build !windows +// +build !windows + +package statsd + +import ( + "bytes" + "encoding/binary" + "errors" + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeAddr is a net.Addr reporting an arbitrary network, which is what tells the connWriter whether +// it needs to frame the payloads it writes. +type fakeAddr string + +func (a fakeAddr) Network() string { return string(a) } +func (a fakeAddr) String() string { return string(a) + ":fake" } + +// fakeConn records everything written to it and fails the writes listed in writeErrors. +type fakeConn struct { + network string + written bytes.Buffer + closed bool + + // writeErrors holds one entry per Write call: a nil entry lets the write go through. + writeErrors []error + writeCount int +} + +func (c *fakeConn) Write(data []byte) (int, error) { + var err error + if c.writeCount < len(c.writeErrors) { + err = c.writeErrors[c.writeCount] + } + c.writeCount++ + + if err != nil { + return 0, err + } + return c.written.Write(data) +} + +func (c *fakeConn) Read(_ []byte) (int, error) { return 0, errors.New("not implemented") } +func (c *fakeConn) Close() error { c.closed = true; return nil } +func (c *fakeConn) LocalAddr() net.Addr { return fakeAddr(c.network) } +func (c *fakeConn) RemoteAddr() net.Addr { return fakeAddr(c.network) } +func (c *fakeConn) SetDeadline(_ time.Time) error { return nil } +func (c *fakeConn) SetReadDeadline(_ time.Time) error { return nil } +func (c *fakeConn) SetWriteDeadline(_ time.Time) error { return nil } + +// fakeDialer hands out the connections it was given, one per dial. +type fakeDialer struct { + conns []*fakeConn + dialCount int + name string +} + +func (d *fakeDialer) dial(_ time.Duration) (net.Conn, error) { + if d.dialCount >= len(d.conns) { + return nil, errors.New("no more connections") + } + conn := d.conns[d.dialCount] + d.dialCount++ + return conn, nil +} + +func (d *fakeDialer) transportName() string { return d.name } + +// timeoutError is a net.Error reporting a timeout, like the error a connection returns when its +// write deadline is reached. +type timeoutError struct{} + +func (e timeoutError) Error() string { return "i/o timeout" } +func (e timeoutError) Timeout() bool { return true } +func (e timeoutError) Temporary() bool { return true } + +func newFakeWriter(network string, conns ...*fakeConn) (*connWriter, *fakeDialer) { + for _, conn := range conns { + conn.network = network + } + + dialer := &fakeDialer{conns: conns, name: writerNameVsock} + return newConnWriter(dialer, 100*time.Millisecond, 1000*time.Millisecond), dialer +} + +// framed returns the length delimited encoding of the given payloads. +func framed(payloads ...string) []byte { + var expected bytes.Buffer + for _, payload := range payloads { + length := []byte{0, 0, 0, 0} + binary.LittleEndian.PutUint32(length, uint32(len(payload))) + expected.Write(length) + expected.WriteString(payload) + } + return expected.Bytes() +} + +// Stream transports, vsock included, need every payload to be prefixed with its length. +func TestConnWriterStreamFraming(t *testing.T) { + conn := &fakeConn{} + w, dialer := newFakeWriter("vsock", conn) + + for _, payload := range []string{"some data", "some more data"} { + n, err := w.Write([]byte(payload)) + require.NoError(t, err) + assert.Equal(t, len(payload), n) + } + + assert.Equal(t, framed("some data", "some more data"), conn.written.Bytes()) + assert.Equal(t, 1, dialer.dialCount, "the connection should have been reused") + assert.Equal(t, writerNameVsock, w.GetTransportName()) +} + +// Datagram transports keep message boundaries on their own and must not be framed. +func TestConnWriterDatagramNoFraming(t *testing.T) { + conn := &fakeConn{} + w, _ := newFakeWriter("unixgram", conn) + + n, err := w.Write([]byte("some data")) + require.NoError(t, err) + assert.Equal(t, len("some data"), n) + + assert.Equal(t, "some data", conn.written.String()) +} + +// A payload that is only partially written leaves the stream out of sync with the length we +// announced, so the connection has to be dropped and re-established. +func TestConnWriterPartialWriteReconnects(t *testing.T) { + // The second write of the first connection is the payload following its length. + failing := &fakeConn{writeErrors: []error{nil, timeoutError{}}} + healthy := &fakeConn{} + w, dialer := newFakeWriter("vsock", failing, healthy) + + _, err := w.Write([]byte("some data")) + require.Error(t, err) + assert.True(t, failing.closed, "the connection should have been closed") + + n, err := w.Write([]byte("some data")) + require.NoError(t, err) + assert.Equal(t, len("some data"), n) + + assert.Equal(t, 2, dialer.dialCount, "a new connection should have been established") + assert.Equal(t, framed("some data"), healthy.written.Bytes()) +} + +// Failing to write the length delimiter leaves the connection in the same unknown state: the Agent +// may have received part of it. +func TestConnWriterLengthWriteFailureReconnects(t *testing.T) { + failing := &fakeConn{writeErrors: []error{errors.New("some write error")}} + healthy := &fakeConn{} + w, dialer := newFakeWriter("vsock", failing, healthy) + + _, err := w.Write([]byte("some data")) + require.Error(t, err) + assert.True(t, failing.closed, "the connection should have been closed") + assert.Equal(t, 0, failing.written.Len()) + + _, err = w.Write([]byte("some data")) + require.NoError(t, err) + assert.Equal(t, 2, dialer.dialCount, "a new connection should have been established") +} + +func TestDialWithRetryOnConnectionRefused(t *testing.T) { + attempts := 0 + conn := &fakeConn{network: "vsock"} + + // Connection refused is retried, as the Agent is likely restarting. + newConn, err := dialWithRetry(10*time.Millisecond, isConnectionRefused, func(_ time.Duration) (net.Conn, error) { + attempts++ + if attempts < 3 { + return nil, errors.New("dial vsock 2:8125: connect: connection refused") + } + return conn, nil + }) + + require.NoError(t, err) + assert.Equal(t, conn, newConn) + assert.Equal(t, 3, attempts) +} + +func TestDialWithRetryGivesUp(t *testing.T) { + attempts := 0 + + // Any other error means retrying is pointless. + _, err := dialWithRetry(10*time.Millisecond, isConnectionRefused, func(_ time.Duration) (net.Conn, error) { + attempts++ + return nil, errors.New("no such device") + }) + + require.Error(t, err) + assert.Equal(t, 1, attempts) +} diff --git a/statsd/statsd_test.go b/statsd/statsd_test.go index 8f31e466..5e4ae25b 100644 --- a/statsd/statsd_test.go +++ b/statsd/statsd_test.go @@ -303,6 +303,10 @@ func TestResolveAddressFromEnvironment(t *testing.T) { {"UDS socket env", "", "unix://test/path.socket", "", "", "unix://test/path.socket"}, {"UDS socket env with port", "", "unix://test/path.socket", "8125", "", "unix://test/path.socket"}, + {"vsock passed", "vsock://2:8125", "", "", "", "vsock://2:8125"}, + {"vsock env", "", "vsock://host:8125", "", "", "vsock://host:8125"}, + {"vsock env with port", "", "vsock://host:8125", "1234", "", "vsock://host:8125"}, + {"Pipe passed", "\\\\.\\pipe\\my_pipe", "", "", "", "\\\\.\\pipe\\my_pipe"}, {"Pipe env", "", "\\\\.\\pipe\\my_pipe", "", "", "\\\\.\\pipe\\my_pipe"}, {"Pipe env with port", "", "\\\\.\\pipe\\my_pipe", "8125", "", "\\\\.\\pipe\\my_pipe"}, @@ -312,6 +316,8 @@ func TestResolveAddressFromEnvironment(t *testing.T) { {"DD_DOGSTATSD_URL UDS", "", "", "", "unix://test/path.socket", "unix://test/path.socket"}, {"DD_DOGSTATSD_URL UDS, ignore env port", "", "", "1234", "udp://198.51.100.123:4321", "198.51.100.123:4321"}, {"DD_DOGSTATSD_URL UDS, ignore env host", "", "localhost", "", "udp://198.51.100.123:4321", "198.51.100.123:4321"}, + {"DD_DOGSTATSD_URL vsock", "", "", "", "vsock://2:8125", "vsock://2:8125"}, + {"DD_DOGSTATSD_URL vsock, ignore env host", "", "localhost", "", "vsock://host:8125", "vsock://host:8125"}, {"DD_DOGSTATSD_URL Pipe", "", "", "", "\\\\.\\pipe\\my_pipe", "\\\\.\\pipe\\my_pipe"}, {"DD_DOGSTATSD_URL with no valid scheme", "", "", "", "localhost:1234", ""}, diff --git a/statsd/statsdex.go b/statsd/statsdex.go index faa3a194..e5e7d246 100644 --- a/statsd/statsdex.go +++ b/statsd/statsdex.go @@ -79,8 +79,15 @@ traffic instead of UDP. */ const WindowsPipeAddressPrefix = `\\.\pipe\` +/* +VsockAddressPrefix holds the prefix to use to enable vsock traffic instead of UDP. The address that +follows is a context ID and a port, such as "vsock://2:8125", where the context ID is either a +number or one of the well-known shorthands: hypervisor, local or host. +*/ +const VsockAddressPrefix = "vsock://" + var ( - AddressPrefixes = []string{UnixAddressPrefix, UnixAddressDatagramPrefix, UnixAddressStreamPrefix, WindowsPipeAddressPrefix} + AddressPrefixes = []string{UnixAddressPrefix, UnixAddressDatagramPrefix, UnixAddressStreamPrefix, WindowsPipeAddressPrefix, VsockAddressPrefix} ) const ( @@ -143,6 +150,7 @@ const ( writerNameUDS string = "uds" writerNameUDSStream string = "uds-stream" writerWindowsPipe string = "pipe" + writerNameVsock string = "vsock" writerNameCustom string = "custom" ) @@ -374,7 +382,7 @@ func parseAgentURL(agentURL string) string { return fmt.Sprintf("%s:%s", parsedURL.Host, defaultUDPPort) } - if parsedURL.Scheme == "unix" { + if parsedURL.Scheme == "unix" || parsedURL.Scheme == "vsock" { return agentURL } } @@ -399,6 +407,9 @@ func createWriter(addr string, writeTimeout time.Duration, connectTimeout time.D case strings.HasPrefix(addr, UnixAddressStreamPrefix): w, err := newUDSWriter(addr[len(UnixAddressStreamPrefix):], writeTimeout, connectTimeout, "unix") return w, writerNameUDS, err + case strings.HasPrefix(addr, VsockAddressPrefix): + w, err := newVsockWriter(addr, writeTimeout, connectTimeout) + return w, writerNameVsock, err default: w, err := newUDPWriter(addr, writeTimeout) return w, writerNameUDP, err @@ -487,24 +498,26 @@ func newWithWriter(w Transport, o *Options, writerName string) (*ClientEx, error } initContainerID(o.containerID, fillInContainerID(o), isHostCgroupNamespace()) - isUDS := writerName == writerNameUDS + // UDS and vsock both talk to a local Agent, which accepts larger payloads than what we can fit + // in a UDP datagram. + isLocalTransport := writerName == writerNameUDS || writerName == writerNameVsock if o.maxBytesPerPayload == 0 { - if isUDS { + if isLocalTransport { o.maxBytesPerPayload = DefaultMaxAgentPayloadSize } else { o.maxBytesPerPayload = OptimalUDPPayloadSize } } if o.bufferPoolSize == 0 { - if isUDS { + if isLocalTransport { o.bufferPoolSize = DefaultUDSBufferPoolSize } else { o.bufferPoolSize = DefaultUDPBufferPoolSize } } if o.senderQueueSize == 0 { - if isUDS { + if isLocalTransport { o.senderQueueSize = DefaultUDSBufferPoolSize } else { o.senderQueueSize = DefaultUDPBufferPoolSize diff --git a/statsd/uds.go b/statsd/uds.go index ed26f3ea..ce6e17c4 100644 --- a/statsd/uds.go +++ b/statsd/uds.go @@ -4,187 +4,64 @@ package statsd import ( - "encoding/binary" "net" "strings" - "sync" "time" ) -// udsWriter is an internal class wrapping around management of UDS connection -type udsWriter struct { +// udsDialer is an internal class connecting to the Agent over a Unix Domain Socket +type udsDialer struct { // Address to send metrics to, needed to allow reconnection on error addr string // Transport used transport string - // Established connection object, or nil if not connected yet - conn net.Conn - // write timeout - writeTimeout time.Duration - // connect timeout - connectTimeout time.Duration - sync.RWMutex // used to lock conn / writer can replace it } -// newUDSWriter returns a pointer to a new udsWriter given a socket file path as addr. -func newUDSWriter(addr string, writeTimeout time.Duration, connectTimeout time.Duration, transport string) (*udsWriter, error) { - // Defer connection to first Write - writer := &udsWriter{addr: addr, transport: transport, conn: nil, writeTimeout: writeTimeout, connectTimeout: connectTimeout} - return writer, nil +// newUDSWriter returns a pointer to a new writer given a socket file path as addr. +func newUDSWriter(addr string, writeTimeout time.Duration, connectTimeout time.Duration, transport string) (*connWriter, error) { + return newConnWriter(&udsDialer{addr: addr, transport: transport}, writeTimeout, connectTimeout), nil } -// GetTransportName returns the transport used by the writer -func (w *udsWriter) GetTransportName() string { - w.RLock() - defer w.RUnlock() - - if w.transport == "unix" { +// transportName returns the transport used by the dialer +func (d *udsDialer) transportName() string { + if d.transport == "unix" { return writerNameUDSStream } else { return writerNameUDS } } -func (w *udsWriter) shouldCloseConnection(err error, partialWrite bool) bool { - if err != nil && partialWrite { - // We can't recover from a partial write - return true - } - if err, isNetworkErr := err.(net.Error); err != nil && (!isNetworkErr || !err.Timeout()) { - // Statsd server disconnected, retry connecting at next packet - return true - } - return false -} - -// Write data to the UDS connection with write timeout and minimal error handling: -// create the connection if nil, and destroy it if the statsd server has disconnected -func (w *udsWriter) Write(data []byte) (int, error) { - var n int - partialWrite := false - conn, err := w.ensureConnection() - if err != nil { - return 0, err - } - stream := conn.LocalAddr().Network() == "unix" - - // When using streams the deadline will only make us drop the packet if we can't write it at all, - // once we've started writing we need to finish. - conn.SetWriteDeadline(time.Now().Add(w.writeTimeout)) - - // When using streams, we append the length of the packet to the data - if stream { - bs := []byte{0, 0, 0, 0} - binary.LittleEndian.PutUint32(bs, uint32(len(data))) - _, err = w.conn.Write(bs) - - partialWrite = true - - // W need to be able to finish to write partially written packets once we have started. - // But we will reset the connection if we can't write anything at all for a long time. - w.conn.SetWriteDeadline(time.Now().Add(w.connectTimeout)) - - // Continue writing only if we've written the length of the packet - if err == nil { - n, err = w.conn.Write(data) - if err == nil { - partialWrite = false - } - } - } else { - n, err = w.conn.Write(data) - } - - if w.shouldCloseConnection(err, partialWrite) { - w.unsetConnection() - } - return n, err -} - -func (w *udsWriter) Close() error { - if w.conn != nil { - return w.conn.Close() - } - return nil -} - -func (w *udsWriter) tryToDial(network string) (net.Conn, error) { - udsAddr, err := net.ResolveUnixAddr(network, w.addr) - if err != nil { - return nil, err - } - - // Try to gracefully reconnect to the socket when we encounter "connection refused", as it's likely that the Agent - // is restarting and the socket is not yet available. - connectAttemptsLeft := 3 - connectDeadline := time.Now().Add(w.connectTimeout) - - // Calculate the backoff time for connection refused errors, but don't exceed one second: this means we won't waste - // longer than 1 seconds worth of time if the socket becomes available immediately after our last connect attempt - connRefusedBackoff := w.connectTimeout / time.Duration(connectAttemptsLeft+1) - if connRefusedBackoff > time.Second { - connRefusedBackoff = time.Second - } - - for { - connectAttemptsLeft-- - - perCallTimeout := time.Until(connectDeadline) - newConn, err := net.DialTimeout(udsAddr.Network(), udsAddr.String(), perCallTimeout) - if err != nil { - if strings.HasSuffix(err.Error(), "connection refused") && connectAttemptsLeft > 0 { - // If we get a connection refused error, we need to wait a bit before trying again. - time.Sleep(connRefusedBackoff) - continue - } - return nil, err - } - return newConn, nil - } -} - -func (w *udsWriter) ensureConnection() (net.Conn, error) { - // Check if we've already got a socket we can use - w.RLock() - currentConn := w.conn - w.RUnlock() - - if currentConn != nil { - return currentConn, nil - } - - // Looks like we might need to connect - try again with write locking. - w.Lock() - defer w.Unlock() - if w.conn != nil { - return w.conn, nil - } - +func (d *udsDialer) dial(connectTimeout time.Duration) (net.Conn, error) { var newConn net.Conn var err error // Try to guess the transport if not specified. - if w.transport == "" { - newConn, err = w.tryToDial("unixgram") + if d.transport == "" { + newConn, err = d.tryToDial("unixgram", connectTimeout) // try to connect with unixgram failed, try again with unix streams. if err != nil && strings.Contains(err.Error(), "protocol wrong type for socket") { - newConn, err = w.tryToDial("unix") + newConn, err = d.tryToDial("unix", connectTimeout) } } else { - newConn, err = w.tryToDial(w.transport) + newConn, err = d.tryToDial(d.transport, connectTimeout) } if err != nil { return nil, err } - w.conn = newConn - w.transport = newConn.RemoteAddr().Network() + d.transport = newConn.RemoteAddr().Network() return newConn, nil } -func (w *udsWriter) unsetConnection() { - w.Lock() - defer w.Unlock() - _ = w.conn.Close() - w.conn = nil +func (d *udsDialer) tryToDial(network string, connectTimeout time.Duration) (net.Conn, error) { + udsAddr, err := net.ResolveUnixAddr(network, d.addr) + if err != nil { + return nil, err + } + + // Try to gracefully reconnect to the socket when we encounter "connection refused", as it's likely that the Agent + // is restarting and the socket is not yet available. + return dialWithRetry(connectTimeout, isConnectionRefused, func(timeout time.Duration) (net.Conn, error) { + return net.DialTimeout(udsAddr.Network(), udsAddr.String(), timeout) + }) } diff --git a/statsd/vsock.go b/statsd/vsock.go new file mode 100644 index 00000000..6751508e --- /dev/null +++ b/statsd/vsock.go @@ -0,0 +1,237 @@ +//go:build linux +// +build linux + +package statsd + +import ( + "fmt" + "io" + "net" + "strings" + "sync" + "time" + + "golang.org/x/sys/unix" +) + +// vsockNetwork is the network name reported by vsock addresses. +const vsockNetwork = "vsock" + +// vsockDialer is an internal class connecting to the Agent over a vsock socket +type vsockDialer struct { + cid uint32 + port uint32 +} + +// newVsockWriter returns a pointer to a new writer given a vsock address as addr. +func newVsockWriter(addr string, writeTimeout time.Duration, connectTimeout time.Duration) (*connWriter, error) { + cid, port, err := parseVsockAddr(addr) + if err != nil { + return nil, err + } + + return newConnWriter(&vsockDialer{cid: cid, port: port}, writeTimeout, connectTimeout), nil +} + +// transportName returns the transport used by the dialer +func (d *vsockDialer) transportName() string { + return writerNameVsock +} + +func (d *vsockDialer) dial(connectTimeout time.Duration) (net.Conn, error) { + // Try to gracefully reconnect to the socket when nothing is listening, as it's likely that the + // Agent is restarting. + return dialWithRetry(connectTimeout, isVsockConnectRetryable, d.tryToDial) +} + +// isVsockConnectRetryable reports whether a failure to connect is worth retrying. On top of the +// usual "connection refused", vsock transports reset the connection when no socket is bound to the +// port we are connecting to. +func isVsockConnectRetryable(err error) bool { + return isConnectionRefused(err) || strings.HasSuffix(err.Error(), "connection reset by peer") +} + +func (d *vsockDialer) tryToDial(connectTimeout time.Duration) (net.Conn, error) { + fd, err := unix.Socket(unix.AF_VSOCK, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0) + if err != nil { + return nil, fmt.Errorf("failed to create vsock socket: %v", err) + } + + // vsock does not honor SO_SNDTIMEO while connecting: af_vsock waits on the socket's own + // connect timeout instead, which defaults to 2 seconds. Set that one so that the connect + // timeout the client was configured with is the one that actually applies. + if err := setSocketTimeout(fd, unix.AF_VSOCK, unix.SO_VM_SOCKETS_CONNECT_TIMEOUT, connectTimeout); err != nil { + unix.Close(fd) + return nil, fmt.Errorf("failed to set vsock connect timeout: %v", err) + } + + if err := unix.Connect(fd, &unix.SockaddrVM{CID: d.cid, Port: d.port}); err != nil { + unix.Close(fd) + // The error is kept at the end of the message so that isVsockConnectRetryable can still + // recognize it. + return nil, fmt.Errorf("failed to connect to vsock CID %d port %d: %v", d.cid, d.port, err) + } + + return newVsockConn(fd, &vsockAddr{cid: d.cid, port: d.port}), nil +} + +// vsockAddr is the net.Addr of a vsock endpoint. +type vsockAddr struct { + cid uint32 + port uint32 +} + +func (a *vsockAddr) Network() string { + return vsockNetwork +} + +func (a *vsockAddr) String() string { + return fmt.Sprintf("%d:%d", a.cid, a.port) +} + +// vsockConn is a minimal net.Conn implementation for AF_VSOCK sockets, which the standard library +// does not support. +// +// The file descriptor is left in blocking mode and deadlines are implemented with SO_SNDTIMEO / +// SO_RCVTIMEO rather than with the runtime poller: os.NewFile only hands sockets over to the poller +// on recent versions of Go, and this client still supports much older ones. +type vsockConn struct { + fd int + local net.Addr + remote net.Addr + + closeOnce sync.Once + + // Deadlines are applied to the socket before each read and write. They are only accessed from + // the goroutine doing the I/O, like the deadlines of the connections returned by net.Dial. + readDeadline time.Time + writeDeadline time.Time +} + +// Verify that vsockConn implements net.Conn. +var _ net.Conn = &vsockConn{} + +func newVsockConn(fd int, remote net.Addr) *vsockConn { + c := &vsockConn{fd: fd, local: &vsockAddr{}, remote: remote} + + // The local address is only used to report the network of the connection, so a failure to + // resolve it is not worth failing the connection over. + if sa, err := unix.Getsockname(fd); err == nil { + if vm, ok := sa.(*unix.SockaddrVM); ok { + c.local = &vsockAddr{cid: vm.CID, port: vm.Port} + } + } + + return c +} + +// Write writes data to the connection, honoring the deadline set by SetWriteDeadline. It returns +// the number of bytes written, which can be less than len(data) if the deadline is reached while +// writing. +func (c *vsockConn) Write(data []byte) (int, error) { + written := 0 + for written < len(data) { + // A blocking socket with SO_SNDTIMEO returns a short write, without an error, when the + // timeout is reached mid-write, so the time left is recomputed on every iteration. + if err := c.applyDeadline(unix.SO_SNDTIMEO, c.writeDeadline); err != nil { + return written, err + } + + n, err := unix.Write(c.fd, data[written:]) + if n > 0 { + written += n + } + if err != nil { + if err == unix.EINTR { + continue + } + return written, err + } + if n <= 0 { + // Not expected for a non-empty buffer, but we'd rather return an error than spin. + return written, unix.EAGAIN + } + } + return written, nil +} + +// Read reads from the connection, honoring the deadline set by SetReadDeadline. +func (c *vsockConn) Read(data []byte) (int, error) { + for { + if err := c.applyDeadline(unix.SO_RCVTIMEO, c.readDeadline); err != nil { + return 0, err + } + + n, err := unix.Read(c.fd, data) + if err == unix.EINTR { + continue + } + if err != nil { + return 0, err + } + if n == 0 && len(data) > 0 { + return 0, io.EOF + } + return n, nil + } +} + +func (c *vsockConn) Close() error { + var err error + c.closeOnce.Do(func() { + err = unix.Close(c.fd) + }) + return err +} + +func (c *vsockConn) LocalAddr() net.Addr { + return c.local +} + +func (c *vsockConn) RemoteAddr() net.Addr { + return c.remote +} + +func (c *vsockConn) SetDeadline(t time.Time) error { + c.readDeadline = t + c.writeDeadline = t + return nil +} + +func (c *vsockConn) SetReadDeadline(t time.Time) error { + c.readDeadline = t + return nil +} + +func (c *vsockConn) SetWriteDeadline(t time.Time) error { + c.writeDeadline = t + return nil +} + +// applyDeadline sets the time left until deadline as a socket timeout. It returns EAGAIN, which +// reports itself as a timeout to net.Error users, when the deadline has already passed. +func (c *vsockConn) applyDeadline(opt int, deadline time.Time) error { + if deadline.IsZero() { + // No deadline: clear any timeout previously set on the socket. + tv := unix.NsecToTimeval(0) + return unix.SetsockoptTimeval(c.fd, unix.SOL_SOCKET, opt, &tv) + } + + timeLeft := time.Until(deadline) + if timeLeft <= 0 { + return unix.EAGAIN + } + return setSocketTimeout(c.fd, unix.SOL_SOCKET, opt, timeLeft) +} + +// setSocketTimeout sets a timeout option on fd. A zero timeval means "no timeout" to the kernel, so +// non-positive timeouts are clamped to the smallest value it understands to make the operation they +// bound fail fast instead of blocking forever. +func setSocketTimeout(fd int, level int, opt int, timeout time.Duration) error { + if timeout < time.Microsecond { + timeout = time.Microsecond + } + + tv := unix.NsecToTimeval(timeout.Nanoseconds()) + return unix.SetsockoptTimeval(fd, level, opt, &tv) +} diff --git a/statsd/vsock_addr.go b/statsd/vsock_addr.go new file mode 100644 index 00000000..5649d517 --- /dev/null +++ b/statsd/vsock_addr.go @@ -0,0 +1,62 @@ +package statsd + +import ( + "fmt" + "net" + "strconv" + "strings" +) + +/* +Well-known vsock context IDs, mirroring the VMADDR_CID_* constants from the kernel. They are +declared here, instead of being taken from golang.org/x/sys/unix, so that vsock addresses can be +parsed on the platforms where vsock itself is not supported. +*/ +const ( + vsockCIDHypervisor uint32 = 0 + vsockCIDLocal uint32 = 1 + vsockCIDHost uint32 = 2 +) + +// vsockCIDNames maps the shorthands accepted in a vsock address to their context ID. +var vsockCIDNames = map[string]uint32{ + "hypervisor": vsockCIDHypervisor, + "local": vsockCIDLocal, + "host": vsockCIDHost, +} + +// parseVsockAddr parses a "vsock://:" address, with or without the scheme, and returns +// the context ID and port it points to. The CID is either a number or one of the well-known +// shorthands: hypervisor, local or host. +func parseVsockAddr(addr string) (uint32, uint32, error) { + cidStr, portStr, err := net.SplitHostPort(strings.TrimPrefix(addr, VsockAddressPrefix)) + if err != nil { + return 0, 0, fmt.Errorf("invalid vsock address %q: %v", addr, err) + } + + cid, err := parseVsockCID(cidStr) + if err != nil { + return 0, 0, fmt.Errorf("invalid vsock address %q: %v", addr, err) + } + + port, err := strconv.ParseUint(portStr, 10, 32) + if err != nil || port == 0 { + return 0, 0, fmt.Errorf("invalid vsock address %q: port must be a number between 1 and 4294967295", addr) + } + + return cid, uint32(port), nil +} + +// parseVsockCID parses a context ID, given either as a number or as one of the well-known +// shorthands. +func parseVsockCID(cidStr string) (uint32, error) { + if cid, found := vsockCIDNames[strings.ToLower(cidStr)]; found { + return cid, nil + } + + cid, err := strconv.ParseUint(cidStr, 10, 32) + if err != nil { + return 0, fmt.Errorf("CID must be a number between 0 and 4294967295, or one of: hypervisor, local, host") + } + return uint32(cid), nil +} diff --git a/statsd/vsock_addr_test.go b/statsd/vsock_addr_test.go new file mode 100644 index 00000000..e6b269b4 --- /dev/null +++ b/statsd/vsock_addr_test.go @@ -0,0 +1,59 @@ +package statsd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseVsockAddr(t *testing.T) { + for _, tc := range []struct { + name string + addr string + expectedCID uint32 + expectedPort uint32 + expectedErr string + }{ + {"numeric CID", "vsock://2:8125", 2, 8125, ""}, + {"CID zero", "vsock://0:8125", 0, 8125, ""}, + {"max CID and port", "vsock://4294967295:4294967295", 4294967295, 4294967295, ""}, + {"without the scheme", "3:8125", 3, 8125, ""}, + + {"hypervisor shorthand", "vsock://hypervisor:8125", vsockCIDHypervisor, 8125, ""}, + {"local shorthand", "vsock://local:8125", vsockCIDLocal, 8125, ""}, + {"host shorthand", "vsock://host:8125", vsockCIDHost, 8125, ""}, + {"shorthand is case insensitive", "vsock://Host:8125", vsockCIDHost, 8125, ""}, + + {"missing port", "vsock://host", 0, 0, "missing port in address"}, + {"empty port", "vsock://host:", 0, 0, "port must be a number"}, + {"port zero", "vsock://host:0", 0, 0, "port must be a number"}, + {"port too large", "vsock://host:4294967296", 0, 0, "port must be a number"}, + {"port is not a number", "vsock://host:statsd", 0, 0, "port must be a number"}, + + {"empty CID", "vsock://:8125", 0, 0, "CID must be a number"}, + {"unknown shorthand", "vsock://guest:8125", 0, 0, "CID must be a number"}, + {"CID too large", "vsock://4294967296:8125", 0, 0, "CID must be a number"}, + {"negative CID", "vsock://-1:8125", 0, 0, "CID must be a number"}, + + {"empty address", "vsock://", 0, 0, "missing port in address"}, + {"too many colons", "vsock://2:8125:9", 0, 0, "too many colons in address"}, + } { + t.Run(tc.name, func(t *testing.T) { + cid, port, err := parseVsockAddr(tc.addr) + + if tc.expectedErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErr) + // The address is always quoted in the error so that users can tell which one of + // their addresses is invalid. + assert.Contains(t, err.Error(), `"`+tc.addr+`"`) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expectedCID, cid) + assert.Equal(t, tc.expectedPort, port) + }) + } +} diff --git a/statsd/vsock_other.go b/statsd/vsock_other.go new file mode 100644 index 00000000..28dfecc8 --- /dev/null +++ b/statsd/vsock_other.go @@ -0,0 +1,14 @@ +//go:build !linux +// +build !linux + +package statsd + +import ( + "fmt" + "time" +) + +// newVsockWriter is disabled outside of Linux: AF_VSOCK is a Linux specific address family. +func newVsockWriter(_ string, _ time.Duration, _ time.Duration) (Transport, error) { + return nil, fmt.Errorf("vsock is only supported on Linux") +} diff --git a/statsd/vsock_test.go b/statsd/vsock_test.go new file mode 100644 index 00000000..e6489694 --- /dev/null +++ b/statsd/vsock_test.go @@ -0,0 +1,265 @@ +//go:build linux +// +build linux + +package statsd + +import ( + "encoding/binary" + "fmt" + "net" + "strings" + "testing" + "time" + + "golang.org/x/sys/unix" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewVsockWriter(t *testing.T) { + w, err := newVsockWriter("vsock://host:8125", 100*time.Millisecond, 1000*time.Millisecond) + require.NoError(t, err) + require.NotNil(t, w) + assert.Equal(t, writerNameVsock, w.GetTransportName()) + + // The address is validated upfront, even though the connection itself is deferred to the first + // write. + w, err = newVsockWriter("vsock://not-a-cid:8125", 100*time.Millisecond, 1000*time.Millisecond) + assert.Error(t, err) + assert.Nil(t, w) +} + +// newVsockTestListener returns a listening vsock socket and the port it is bound to. The test is +// skipped when the machine can't talk to itself over vsock: AF_VSOCK needs the vsock module, and +// VMADDR_CID_LOCAL needs the vsock_loopback one (Linux 5.6+), neither of which is a given on a CI +// runner or inside a container. +func newVsockTestListener(t *testing.T) (int, uint32) { + fd, err := unix.Socket(unix.AF_VSOCK, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0) + if err != nil { + t.Skipf("vsock is not available on this machine: %v", err) + } + + // Bound so that a missing connection never hangs the test suite. + if err := setSocketTimeout(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, 10*time.Second); err != nil { + unix.Close(fd) + require.NoError(t, err) + } + + if err := unix.Bind(fd, &unix.SockaddrVM{CID: unix.VMADDR_CID_ANY, Port: unix.VMADDR_PORT_ANY}); err != nil { + unix.Close(fd) + t.Skipf("could not bind a vsock socket on this machine: %v", err) + } + if err := unix.Listen(fd, 2); err != nil { + unix.Close(fd) + t.Skipf("could not listen on a vsock socket on this machine: %v", err) + } + + sa, err := unix.Getsockname(fd) + if err != nil { + unix.Close(fd) + require.NoError(t, err) + } + port := sa.(*unix.SockaddrVM).Port + + // Check that loopback connections actually work before handing the listener over. + probe, err := unix.Socket(unix.AF_VSOCK, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0) + if err != nil { + unix.Close(fd) + t.Skipf("vsock is not available on this machine: %v", err) + } + // Bound so that a machine on which loopback connections hang, rather than fail, doesn't hang the + // test suite with them. + if err := setSocketTimeout(probe, unix.SOL_SOCKET, unix.SO_SNDTIMEO, 10*time.Second); err != nil { + unix.Close(probe) + unix.Close(fd) + require.NoError(t, err) + } + err = unix.Connect(probe, &unix.SockaddrVM{CID: unix.VMADDR_CID_LOCAL, Port: port}) + unix.Close(probe) + if err != nil { + unix.Close(fd) + t.Skipf("vsock loopback is not available on this machine: %v", err) + } + if probeFd, _, err := unix.Accept(fd); err == nil { + unix.Close(probeFd) + } + + return fd, port +} + +// readFully reads exactly len(buffer) bytes from fd. +func readFully(t *testing.T, fd int, buffer []byte) { + for read := 0; read < len(buffer); { + n, err := unix.Read(fd, buffer[read:]) + require.NoError(t, err) + require.NotZero(t, n, "connection closed before the whole payload was read") + read += n + } +} + +func TestVsockStreamWrite(t *testing.T) { + listener, port := newVsockTestListener(t) + defer unix.Close(listener) + + w, err := newVsockWriter(fmt.Sprintf("vsock://local:%d", port), 100*time.Millisecond, 1000*time.Millisecond) + require.NoError(t, err) + defer w.Close() + + conn := -1 + + // test 2 Write: the first one should setup the connection + for i := 0; i < 2; i++ { + msg := []byte("some data") + n, err := w.Write(msg) + require.NoError(t, err) + assert.Equal(t, len(msg), n) + + // This works because the kernel accepts sockets before the accept call + if conn < 0 { + conn, _, err = unix.Accept(listener) + require.NoError(t, err) + defer unix.Close(conn) + } + + buffer := make([]byte, 4+len(msg)) + readFully(t, conn, buffer) + assert.Equal(t, uint32(len(msg)), binary.LittleEndian.Uint32(buffer[:4])) + assert.Equal(t, "some data", string(buffer[4:])) + } +} + +func TestVsockStreamWriteUnsetConnection(t *testing.T) { + listener, port := newVsockTestListener(t) + defer unix.Close(listener) + + w, err := newVsockWriter(fmt.Sprintf("vsock://local:%d", port), 100*time.Millisecond, 1000*time.Millisecond) + require.NoError(t, err) + defer w.Close() + + // Each iteration reconnects, as the Agent would force us to do when it restarts. + for i := 0; i < 2; i++ { + msg := []byte("some data") + n, err := w.Write(msg) + require.NoError(t, err) + assert.Equal(t, len(msg), n) + + conn, _, err := unix.Accept(listener) + require.NoError(t, err) + + buffer := make([]byte, 4+len(msg)) + readFully(t, conn, buffer) + assert.Equal(t, uint32(len(msg)), binary.LittleEndian.Uint32(buffer[:4])) + assert.Equal(t, "some data", string(buffer[4:])) + + unix.Close(conn) + w.unsetConnection() + } +} + +// vsock ignores SO_SNDTIMEO while connecting and waits on its own connect timeout instead, which +// defaults to 2 seconds. Check that the timeout the client is configured with is the one that ends +// up on the socket, otherwise a peer that never answers blocks the sender for longer than asked. +func TestVsockConnectTimeoutIsAppliedToTheSocket(t *testing.T) { + listener, port := newVsockTestListener(t) + defer unix.Close(listener) + + connectTimeout := 250 * time.Millisecond + w, err := newVsockWriter(fmt.Sprintf("vsock://local:%d", port), 100*time.Millisecond, connectTimeout) + require.NoError(t, err) + defer w.Close() + + conn, err := w.ensureConnection() + require.NoError(t, err) + + tv, err := unix.GetsockoptTimeval(conn.(*vsockConn).fd, unix.AF_VSOCK, unix.SO_VM_SOCKETS_CONNECT_TIMEOUT) + require.NoError(t, err) + + applied := time.Duration(tv.Sec)*time.Second + time.Duration(tv.Usec)*time.Microsecond + // The kernel stores the timeout in jiffies, so it is rounded up to the resolution of the clock + // tick this kernel was built with. + assert.True(t, applied >= connectTimeout && applied < connectTimeout+50*time.Millisecond, + "expected roughly %v, got %v", connectTimeout, applied) +} + +// A payload we can't even start to write is reported as a timeout, and costs us the connection +// since the length delimiter announcing it has already been sent. +func TestVsockStreamPartialWrite(t *testing.T) { + listener, port := newVsockTestListener(t) + defer unix.Close(listener) + + w, err := newVsockWriter(fmt.Sprintf("vsock://local:%d", port), 100*time.Millisecond, 1000*time.Millisecond) + require.NoError(t, err) + defer w.Close() + + // Force a connection + _, err = w.ensureConnection() + require.NoError(t, err) + conn, _, err := unix.Accept(listener) + require.NoError(t, err) + defer unix.Close(conn) + + // On linux we need to force a timeout this way + w.connectTimeout = -1 * time.Millisecond + + msg := []byte("some data") + n, err := w.Write(msg) + require.Error(t, err) + assert.True(t, n < len(msg), "n: %d, len(msg): %d", n, len(msg)) + + // The writer relies on timeouts being reported as net.Error to tell them apart from a + // disconnected Agent. + netErr, ok := err.(net.Error) + require.True(t, ok, "expected a net.Error, got %T: %v", err, err) + assert.True(t, netErr.Timeout()) + + // The connection should be dropped + assert.Nil(t, w.conn) +} + +// The client can be built from a vsock address and reports vsock as its transport. +func TestVsockClient(t *testing.T) { + listener, port := newVsockTestListener(t) + defer unix.Close(listener) + + client, err := NewEx(fmt.Sprintf("vsock://local:%d", port), WithoutOriginDetection()) + require.NoError(t, err) + defer client.Close() + + assert.Equal(t, writerNameVsock, client.GetTransport()) + + require.NoError(t, client.Gauge("my.gauge", 1, nil, 1)) + require.NoError(t, client.Flush()) + + conn, _, err := unix.Accept(listener) + require.NoError(t, err) + defer unix.Close(conn) + + // The payload itself depends on the global tags the environment adds, so only its length + // delimiter and the metric it starts with are checked. + length := make([]byte, 4) + readFully(t, conn, length) + payload := make([]byte, binary.LittleEndian.Uint32(length)) + readFully(t, conn, payload) + assert.True(t, strings.HasPrefix(string(payload), "my.gauge:1|g"), "unexpected payload: %q", payload) +} + +// Connecting to a port nobody listens on is retried a few times, as the Agent may just be +// restarting. Note that vsock resets the connection instead of refusing it. +func TestVsockConnectionRetriedWhenNobodyListens(t *testing.T) { + listener, port := newVsockTestListener(t) + unix.Close(listener) + + connectTimeout := 400 * time.Millisecond + w, err := newVsockWriter(fmt.Sprintf("vsock://local:%d", port), 100*time.Millisecond, connectTimeout) + require.NoError(t, err) + defer w.Close() + + start := time.Now() + _, err = w.Write([]byte("some data")) + require.Error(t, err) + assert.True(t, isVsockConnectRetryable(err), "unexpected error: %v", err) + + // 3 attempts, so 2 backoffs of connectTimeout/4 between them. + assert.True(t, time.Since(start) >= connectTimeout/2, "the connection should have been retried") +}