diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ef8afb..a1d6de6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,13 @@ Alle wesentlichen Änderungen am Projekt werden in dieser Datei dokumentiert. - Integrationstest mit virtuellen seriellen Schnittstellen über `socat` - Tests für TCP → RS232 und RS232 → TCP - Tests für Session-Reconnect und Ressourcenverwaltung +- Client-Control-Verbindung unter `internal/client` +- Clientseitige Verarbeitung von `HELLO` +- Clientseitige Abfrage der konfigurierten Geräte über `GET_DEVICES` +- Clientseitige Verarbeitung der vom Server gelieferten Geräteinformationen einschließlich dynamischem Data-Port +- Clientseitiger Aufbau der TCP-Data-Verbindung +- Integrationstest für die vollständige Client-/Server-Kommunikation +- End-to-End-Test der TCP-/RS232-Datenübertragung in beide Richtungen ### Changed @@ -36,33 +43,55 @@ Alle wesentlichen Änderungen am Projekt werden in dieser Datei dokumentiert. - `go test ./...` erfolgreich - `go test -race ./...` erfolgreich - Race Condition im `DataListener` erkannt und behoben +- Client-/Server-Integrationstest erfolgreich +- Race-Test des Client-/Server-Integrationstests erfolgreich ### Status -Die grundlegende Server-seitige TCP-/RS232-Datenübertragung ist implementiert und durch automatisierte Tests abgesichert. +Die grundlegende TCP-/RS232-Datenübertragung ist serverseitig implementiert und der Client kann inzwischen die Control-Verbindung sowie die zugehörige Data-Verbindung aufbauen. -Der Datenpfad ist derzeit: +Der aktuelle Datenpfad ist: ```text -TCP-Control-Verbindung - │ - ▼ - Session - │ - ▼ - dynamischer Data-Port - │ - ▼ - TCP-Data-Verbindung - │ - ▼ - internal/serial - │ - ▼ - RS232-Gerät +Client + │ + ├── TCP-Control-Verbindung + │ │ + │ ▼ + │ Session + │ │ + │ ▼ + │ GET_DEVICES + │ │ + │ ▼ + │ Geräteinformationen + │ │ + │ ▼ + │ dynamischer Data-Port + │ │ + └── TCP-Data-Verbindung + │ + ▼ + DataListener + │ + ▼ + DataConnection + │ + ▼ + internal/serial + │ + ▼ + RS232-Gerät ``` -Die Client-seitige Bereitstellung einer virtuellen seriellen Schnittstelle ist noch nicht implementiert. +Die Datenübertragung wurde bereits in beide Richtungen erfolgreich getestet: + +```text +TCP → RS232 +RS232 → TCP +``` + +Die clientseitige Bereitstellung einer virtuellen seriellen Schnittstelle für die vorhandene Hersteller-Software ist noch nicht implementiert. --- diff --git a/README.md b/README.md index bf60c9b..baa12b1 100644 --- a/README.md +++ b/README.md @@ -40,28 +40,40 @@ rs2322tcp-client │ Funkgerät Rotor ``` -## Aktueller Server-Datenpfad +## Aktueller Datenpfad -Die serverseitige TCP-/RS232-Verbindung ist inzwischen implementiert: +Die serverseitige TCP-/RS232-Verbindung sowie die grundlegende Client-Control- und Client-Data-Kommunikation sind inzwischen implementiert: ```text -TCP-Control - │ - ▼ - Session - │ - ├── Gerät 1 ──► dynamischer Data-Port - │ │ - │ ▼ - │ TCP Data - │ │ - │ ▼ - │ Serial Layer - │ │ - │ ▼ - │ RS232 - │ - └── Gerät 2 ──► dynamischer Data-Port +Client + │ + ├── TCP-Control + │ │ + │ ▼ + │ Session + │ │ + │ ▼ + │ GET_DEVICES + │ │ + │ ▼ + │ Geräteinformationen + │ │ + │ ▼ + │ dynamischer Data-Port + │ + └── TCP-Data + │ + ▼ + DataListener + │ + ▼ + DataConnection + │ + ▼ + Serial Layer + │ + ▼ + RS232 ``` Die serielle Schnittstelle wird erst geöffnet, wenn ein Client tatsächlich eine Data-Verbindung zum entsprechenden dynamischen TCP-Port aufbaut. @@ -75,6 +87,8 @@ TCP ───────────────► RS232 TCP ◄────────────── RS232 ``` +Der komplette Client-/Server-Datenpfad wird inzwischen durch einen Integrationstest mit virtuellen seriellen Schnittstellen geprüft. + ## Client Der Client soll gleichberechtigt unter folgenden Betriebssystemen eingesetzt werden können: @@ -84,7 +98,15 @@ Der Client soll gleichberechtigt unter folgenden Betriebssystemen eingesetzt wer Die plattformspezifische Bereitstellung der virtuellen seriellen Schnittstelle wird vom gemeinsamen Client-Kern getrennt. -Die Client-seitige virtuelle serielle Schnittstelle ist derzeit noch nicht implementiert. +Der gemeinsame Client-Kern kann inzwischen: + +- die Control-Verbindung zum Server aufbauen +- den Control-Handshake durchführen +- die konfigurierten Geräte vom Server abfragen +- den vom Server gelieferten dynamischen Data-Port verwenden +- die TCP-Data-Verbindung zum Server aufbauen + +Die clientseitige virtuelle serielle Schnittstelle ist derzeit noch nicht implementiert. ## Server @@ -172,6 +194,8 @@ TCP → RS232 RS232 → TCP ``` +Zusätzlich wird die vollständige Client-/Server-Kommunikation über den echten `ControlServer`-Lifecycle (`Listen()` / `Serve()`) getestet. + ## Projektstruktur ```text @@ -232,18 +256,23 @@ Release-Versionen werden über Git-Tags gekennzeichnet. Das Projekt befindet sich weiterhin in der frühen Entwicklungsphase. -Die ursprüngliche Projektgrundlage aus Version `0.0.1` wurde inzwischen um eine funktionierende serverseitige TCP-/RS232-Datenübertragung erweitert. +Die ursprüngliche Projektgrundlage aus Version `0.0.1` wurde inzwischen um eine funktionierende serverseitige TCP-/RS232-Datenübertragung und die grundlegende Client-Control-/Data-Kommunikation erweitert. Implementiert und getestet sind derzeit: - Server-Control-Verbindung +- Client-Control-Verbindung +- `HELLO`-Handshake +- `GET_DEVICES` - Session-Verwaltung - dynamische Data-Ports +- Clientseitiger Aufbau der Data-Verbindung - DataConnection - bidirektionale TCP-/RS232-Datenübertragung - Serial-Abstraktion - Konfiguration der seriellen Parameter - virtuelle serielle Integrationstests +- Client-/Server-End-to-End-Integrationstest - nebenläufigkeitssichere Data-Listener - Race-Detection diff --git a/internal/client/control.go b/internal/client/control.go new file mode 100644 index 0000000..acb7fbd --- /dev/null +++ b/internal/client/control.go @@ -0,0 +1,172 @@ +/* + * ============================================================================ + * Projekt.....: rs2322tcp + * Datei.......: control.go + * Copyright (C) 2026 Dieter Lang + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Beschreibung: + * Client-seitige Control-Verbindung zum rs2322tcp-Server. + * Baut die TCP-Control-Verbindung auf, führt den Protokoll-Handshake durch + * und liest die vom Server bereitgestellten Geräteinformationen. + * ============================================================================ + */ +package client + +import ( + "bufio" + "fmt" + "net" + + "git.lang-dieter.de/rs2322tcp/internal/transport" +) + +/////////////////////////////////////////////////////////////////////////////// +// Client +/////////////////////////////////////////////////////////////////////////////// + +// Client represents a connection to an rs2322tcp server. +type Client struct { + conn net.Conn + reader *bufio.Reader +} + +/////////////////////////////////////////////////////////////////////////////// +// Constructor +/////////////////////////////////////////////////////////////////////////////// + +// New creates a new client and connects to the specified server address. +// +// The address must be in the form "host:port". +func New(address string) (*Client, error) { + if address == "" { + return nil, fmt.Errorf("server address is empty") + } + + conn, err := net.Dial("tcp", address) + if err != nil { + return nil, fmt.Errorf( + "connect to server %q: %w", + address, + err, + ) + } + + client := &Client{ + conn: conn, + reader: bufio.NewReader(conn), + } + + if err := client.hello(); err != nil { + _ = conn.Close() + + return nil, err + } + + return client, nil +} + +/////////////////////////////////////////////////////////////////////////////// +// Properties +/////////////////////////////////////////////////////////////////////////////// + +// Conn returns the underlying control connection. +// +// It returns nil if the client is nil or has already been closed. +func (c *Client) Conn() net.Conn { + if c == nil { + return nil + } + + return c.conn +} + +/////////////////////////////////////////////////////////////////////////////// +// Handshake +/////////////////////////////////////////////////////////////////////////////// + +func (c *Client) hello() error { + if c == nil || c.conn == nil { + return fmt.Errorf("client is not connected") + } + + if err := transport.WriteMessage( + c.conn, + transport.NewHello(), + ); err != nil { + return fmt.Errorf("send hello: %w", err) + } + + var response transport.HelloResponseMessage + + if err := transport.ReadMessage( + c.reader, + &response, + ); err != nil { + return fmt.Errorf("read hello response: %w", err) + } + + if response.Type != transport.MessageHelloResponse { + return fmt.Errorf( + "unexpected hello response: %q", + response.Type, + ) + } + + return nil +} + +/////////////////////////////////////////////////////////////////////////////// +// Devices +/////////////////////////////////////////////////////////////////////////////// + +// GetDevices requests the configured remote devices from the server. +// +// The returned list contains the session-specific dynamic TCP data ports. +func (c *Client) GetDevices() ([]transport.RemoteDeviceInfo, error) { + if c == nil || c.conn == nil { + return nil, fmt.Errorf("client is not connected") + } + + if err := transport.WriteMessage( + c.conn, + transport.NewGetDevices(), + ); err != nil { + return nil, fmt.Errorf("send get-devices request: %w", err) + } + + var response transport.DeviceListMessage + + if err := transport.ReadMessage( + c.reader, + &response, + ); err != nil { + return nil, fmt.Errorf("read device list: %w", err) + } + + if response.Type != transport.MessageDeviceList { + return nil, fmt.Errorf( + "unexpected device list response: %q", + response.Type, + ) + } + + return response.Devices, nil +} + +/////////////////////////////////////////////////////////////////////////////// +// Close +/////////////////////////////////////////////////////////////////////////////// + +// Close closes the client control connection. +func (c *Client) Close() error { + if c == nil || c.conn == nil { + return nil + } + + err := c.conn.Close() + c.conn = nil + + return err +} diff --git a/internal/client/control_test.go b/internal/client/control_test.go new file mode 100644 index 0000000..a4dc0d0 --- /dev/null +++ b/internal/client/control_test.go @@ -0,0 +1,168 @@ +package client_test + +import ( + "bufio" + "net" + "testing" + + "git.lang-dieter.de/rs2322tcp/internal/client" + "git.lang-dieter.de/rs2322tcp/internal/transport" +) + +func startTestServer(t *testing.T, handler func(net.Conn)) string { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen failed: %v", err) + } + + t.Cleanup(func() { + _ = listener.Close() + }) + + go func() { + conn, err := listener.Accept() + if err != nil { + return + } + + handler(conn) + }() + + return listener.Addr().String() +} + +func TestClientHello(t *testing.T) { + address := startTestServer(t, func(conn net.Conn) { + defer conn.Close() + + reader := bufio.NewReader(conn) + + var hello transport.HelloMessage + + if err := transport.ReadMessage(reader, &hello); err != nil { + return + } + + if hello.Type != transport.MessageHello { + return + } + + _ = transport.WriteMessage( + conn, + transport.NewHelloResponse(), + ) + }) + + c, err := client.New(address) + if err != nil { + t.Fatalf("client.New() failed: %v", err) + } + defer c.Close() +} + +func TestClientGetDevices(t *testing.T) { + address := startTestServer(t, func(conn net.Conn) { + defer conn.Close() + + reader := bufio.NewReader(conn) + + // HELLO + var hello transport.HelloMessage + + if err := transport.ReadMessage(reader, &hello); err != nil { + return + } + + if hello.Type != transport.MessageHello { + return + } + + if err := transport.WriteMessage( + conn, + transport.NewHelloResponse(), + ); err != nil { + return + } + + // GET_DEVICES + var request transport.GetDevicesMessage + + if err := transport.ReadMessage(reader, &request); err != nil { + return + } + + if request.Type != transport.MessageGetDevices { + return + } + + response := transport.NewDeviceList([]transport.RemoteDeviceInfo{ + { + ID: "radio", + Name: "Funkgerät", + DataPort: 4711, + BaudRate: 9600, + DataBits: 8, + Parity: "none", + StopBits: 1, + }, + { + ID: "rotor", + Name: "Antennenrotor", + DataPort: 4712, + BaudRate: 4800, + DataBits: 8, + Parity: "none", + StopBits: 1, + }, + }) + + _ = transport.WriteMessage(conn, response) + }) + + c, err := client.New(address) + if err != nil { + t.Fatalf("client.New() failed: %v", err) + } + defer c.Close() + + devices, err := c.GetDevices() + if err != nil { + t.Fatalf("GetDevices() failed: %v", err) + } + + if len(devices) != 2 { + t.Fatalf("len(devices) = %d, want 2", len(devices)) + } + + if devices[0].ID != "radio" { + t.Errorf( + "devices[0].ID = %q, want %q", + devices[0].ID, + "radio", + ) + } + + if devices[0].DataPort != 4711 { + t.Errorf( + "devices[0].DataPort = %d, want 4711", + devices[0].DataPort, + ) + } + + if devices[1].ID != "rotor" { + t.Errorf( + "devices[1].ID = %q, want %q", + devices[1].ID, + "rotor", + ) + } + + if devices[1].DataPort != 4712 { + t.Errorf( + "devices[1].DataPort = %d, want 4712", + devices[1].DataPort, + ) + } +} diff --git a/internal/client/data.go b/internal/client/data.go new file mode 100644 index 0000000..95f1a34 --- /dev/null +++ b/internal/client/data.go @@ -0,0 +1,62 @@ +package client + +import ( + "fmt" + "net" + + "git.lang-dieter.de/rs2322tcp/internal/transport" +) + +/////////////////////////////////////////////////////////////////////////////// +// Data connection +/////////////////////////////////////////////////////////////////////////////// + +// OpenDataConnection opens the TCP data connection for the specified +// remote device. +// +// The DataPort is supplied by the server as part of the device information. +func (c *Client) OpenDataConnection( + device transport.RemoteDeviceInfo, +) (net.Conn, error) { + if c == nil || c.conn == nil { + return nil, fmt.Errorf("client is not connected") + } + + if device.DataPort == 0 { + return nil, fmt.Errorf( + "device %q has no data port", + device.ID, + ) + } + + serverAddr := c.conn.RemoteAddr() + if serverAddr == nil { + return nil, fmt.Errorf("control connection has no remote address") + } + + host, _, err := net.SplitHostPort(serverAddr.String()) + if err != nil { + return nil, fmt.Errorf( + "invalid control server address %q: %w", + serverAddr.String(), + err, + ) + } + + dataAddr := net.JoinHostPort( + host, + fmt.Sprintf("%d", device.DataPort), + ) + + conn, err := net.Dial("tcp", dataAddr) + if err != nil { + return nil, fmt.Errorf( + "connect data port %d for device %q: %w", + device.DataPort, + device.ID, + err, + ) + } + + return conn, nil +} diff --git a/internal/client/data_test.go b/internal/client/data_test.go new file mode 100644 index 0000000..0e36c6f --- /dev/null +++ b/internal/client/data_test.go @@ -0,0 +1,120 @@ +package client_test + +import ( + "bufio" + "net" + "testing" + + "git.lang-dieter.de/rs2322tcp/internal/client" + "git.lang-dieter.de/rs2322tcp/internal/transport" +) + +func TestClientOpenDataConnection(t *testing.T) { + dataListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("data listener failed: %v", err) + } + defer dataListener.Close() + + dataPort := dataListener.Addr().(*net.TCPAddr).Port + + controlListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("control listener failed: %v", err) + } + defer controlListener.Close() + + serverDone := make(chan error, 1) + + go func() { + conn, err := controlListener.Accept() + if err != nil { + serverDone <- err + return + } + defer conn.Close() + + reader := bufio.NewReader(conn) + + var hello transport.HelloMessage + + if err := transport.ReadMessage(reader, &hello); err != nil { + serverDone <- err + return + } + + if hello.Type != transport.MessageHello { + serverDone <- &testError{ + message: "unexpected hello message type", + } + return + } + + if err := transport.WriteMessage( + conn, + transport.NewHelloResponse(), + ); err != nil { + serverDone <- err + return + } + + serverDone <- nil + }() + + clientConn, err := client.New(controlListener.Addr().String()) + if err != nil { + t.Fatalf("client.New() failed: %v", err) + } + defer clientConn.Close() + + device := transport.RemoteDeviceInfo{ + ID: "radio", + Name: "Funkgerät", + DataPort: dataPort, + } + + dataConn, err := clientConn.OpenDataConnection(device) + if err != nil { + t.Fatalf("OpenDataConnection() failed: %v", err) + } + defer dataConn.Close() + + serverDataConn, err := dataListener.Accept() + if err != nil { + t.Fatalf("data listener Accept() failed: %v", err) + } + defer serverDataConn.Close() + + if err := <-serverDone; err != nil { + t.Fatalf("control server failed: %v", err) + } + + // Verify that the data connection is actually usable. + message := []byte("test") + + if _, err := dataConn.Write(message); err != nil { + t.Fatalf("data connection Write() failed: %v", err) + } + + buffer := make([]byte, len(message)) + + if _, err := serverDataConn.Read(buffer); err != nil { + t.Fatalf("data connection Read() failed: %v", err) + } + + if string(buffer) != string(message) { + t.Fatalf( + "received %q, want %q", + string(buffer), + string(message), + ) + } +} + +type testError struct { + message string +} + +func (e *testError) Error() string { + return e.message +} diff --git a/internal/client/integration_test.go b/internal/client/integration_test.go new file mode 100644 index 0000000..5735cd6 --- /dev/null +++ b/internal/client/integration_test.go @@ -0,0 +1,308 @@ +/* + * ============================================================================ + * Projekt.....: rs2322tcp + * Datei.......: integration_test.go + * Copyright (C) 2026 Dieter Lang + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Beschreibung: + * Integrationstest für die Client-/Server-Kommunikation einschließlich + * serieller Datenübertragung über virtuelle serielle Schnittstellen. + * ============================================================================ + */ + +package client_test + +import ( + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "git.lang-dieter.de/rs2322tcp/internal/client" + "git.lang-dieter.de/rs2322tcp/internal/config" + "git.lang-dieter.de/rs2322tcp/internal/server" +) + +func startVirtualSerialPair(t *testing.T) (string, string, func()) { + t.Helper() + + if _, err := exec.LookPath("socat"); err != nil { + t.Skip("socat not installed") + } + + dir := t.TempDir() + + portA := filepath.Join(dir, "ttyA") + portB := filepath.Join(dir, "ttyB") + + cmd := exec.Command( + "socat", + "-d", + "-d", + fmt.Sprintf("pty,raw,echo=0,link=%s", portA), + fmt.Sprintf("pty,raw,echo=0,link=%s", portB), + ) + + if err := cmd.Start(); err != nil { + t.Fatalf("start socat: %v", err) + } + + cleanup := func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + } + } + + deadline := time.Now().Add(2 * time.Second) + + for { + _, errA := os.Stat(portA) + _, errB := os.Stat(portB) + + if errA == nil && errB == nil { + return portA, portB, cleanup + } + + if time.Now().After(deadline) { + cleanup() + t.Fatal("timeout waiting for virtual serial ports") + } + + time.Sleep(10 * time.Millisecond) + } +} + +func readExactWithTimeout( + t *testing.T, + reader io.Reader, + buffer []byte, + timeout time.Duration, +) { + t.Helper() + + done := make(chan error, 1) + + go func() { + _, err := io.ReadFull(reader, buffer) + done <- err + }() + + select { + case err := <-done: + if err != nil { + t.Fatalf("read failed: %v", err) + } + + case <-time.After(timeout): + t.Fatalf("read timeout after %s", timeout) + } +} + +func TestClientServerSerialIntegration(t *testing.T) { + serialA, serialB, cleanup := startVirtualSerialPair(t) + defer cleanup() + + //////////////////////////////////////////////////////////////////////////// + // Server + //////////////////////////////////////////////////////////////////////////// + + controlProbe, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("find free control port: %v", err) + } + + controlAddr := controlProbe.Addr().(*net.TCPAddr) + controlPort := controlAddr.Port + + if err := controlProbe.Close(); err != nil { + t.Fatalf("close control probe: %v", err) + } + + cfg := &config.ServerConfig{ + Listen: config.ListenConfig{ + Address: "127.0.0.1", + Port: controlPort, + }, + Devices: []config.DeviceConfig{ + { + ID: "radio", + Name: "Funkgerät", + SerialPort: serialA, + BaudRate: 9600, + DataBits: 8, + Parity: "none", + StopBits: 1, + }, + }, + } + + srv, err := server.NewControlServer(cfg) + if err != nil { + t.Fatalf("NewControlServer() failed: %v", err) + } + + //////////////////////////////////////////////////////////////////////////// + // Control server + //////////////////////////////////////////////////////////////////////////// + + if err := srv.Listen(); err != nil { + t.Fatalf("server Listen() failed: %v", err) + } + + serveDone := make(chan error, 1) + + go func() { + serveDone <- srv.Serve() + }() + + address := srv.Addr().String() + + //////////////////////////////////////////////////////////////////////////// + // Client + //////////////////////////////////////////////////////////////////////////// + + c, err := client.New(address) + if err != nil { + t.Fatalf("client.New() failed: %v", err) + } + defer c.Close() + + devices, err := c.GetDevices() + if err != nil { + t.Fatalf("GetDevices() failed: %v", err) + } + + if len(devices) != 1 { + t.Fatalf( + "len(devices) = %d, want 1", + len(devices), + ) + } + + device := devices[0] + + if device.ID != "radio" { + t.Fatalf( + "device ID = %q, want %q", + device.ID, + "radio", + ) + } + + if device.DataPort == 0 { + t.Fatal("server returned data port 0") + } + + //////////////////////////////////////////////////////////////////////////// + // Open data connection + //////////////////////////////////////////////////////////////////////////// + + dataConn, err := c.OpenDataConnection(device) + if err != nil { + t.Fatalf("OpenDataConnection() failed: %v", err) + } + defer dataConn.Close() + + //////////////////////////////////////////////////////////////////////////// + // Open serial peer + //////////////////////////////////////////////////////////////////////////// + + serialPeer, err := os.OpenFile( + serialB, + os.O_RDWR, + 0, + ) + if err != nil { + t.Fatalf("open serial peer: %v", err) + } + defer serialPeer.Close() + + //////////////////////////////////////////////////////////////////////////// + // Client -> TCP -> Server -> Serial + //////////////////////////////////////////////////////////////////////////// + + clientMessage := []byte("hello from client") + + if _, err := dataConn.Write(clientMessage); err != nil { + t.Fatalf("client data Write() failed: %v", err) + } + + serialReceived := make([]byte, len(clientMessage)) + + readExactWithTimeout( + t, + serialPeer, + serialReceived, + 2*time.Second, + ) + + if string(serialReceived) != string(clientMessage) { + t.Fatalf( + "serial received %q, want %q", + string(serialReceived), + string(clientMessage), + ) + } + + //////////////////////////////////////////////////////////////////////////// + // Serial -> Server -> TCP -> Client + //////////////////////////////////////////////////////////////////////////// + + serialMessage := []byte("hello from serial") + + if _, err := serialPeer.Write(serialMessage); err != nil { + t.Fatalf("serial Write() failed: %v", err) + } + + clientReceived := make([]byte, len(serialMessage)) + + readExactWithTimeout( + t, + dataConn, + clientReceived, + 2*time.Second, + ) + + if string(clientReceived) != string(serialMessage) { + t.Fatalf( + "client received %q, want %q", + string(clientReceived), + string(serialMessage), + ) + } + + //////////////////////////////////////////////////////////////////////////// + // Cleanup + //////////////////////////////////////////////////////////////////////////// + + if err := dataConn.Close(); err != nil { + t.Fatalf("close data connection: %v", err) + } + + if err := c.Close(); err != nil { + t.Fatalf("close client: %v", err) + } + + // Give the server goroutines a moment to perform their cleanup. + time.Sleep(20 * time.Millisecond) + + if err := srv.Close(); err != nil { + t.Fatalf("close server: %v", err) + } + + select { + case err := <-serveDone: + if err != nil { + t.Fatalf("server Serve() failed: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for server Serve() to stop") + } +}