speed up tests and update dependencies

- Override wsHandshakeTimeout in 3 websocket tests that waited 30s each
- Make initialRetryDelay a package variable for faster retry tests
- Reduce time.Sleep calls across agent_test.go (1.5s->150ms, 3s->1s, etc.)
- Update Makefile with test-fast and test-short targets, add -count=1
- Update golang.org/x/crypto, x/net, x/sys to latest minors
- Fix TestWSDialDefaultPorts to use mocked dials instead of real TCP
This commit is contained in:
Graham McIntire 2026-06-06 13:50:54 -05:00
parent b308c3dc2e
commit 217f85fbdd
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
8 changed files with 135 additions and 57 deletions

View file

@ -19,7 +19,7 @@ jobs:
go-version-file: go.mod
- name: Run tests
run: go test -race -v ./...
run: go test -race -v -count=1 -timeout 60s ./...
- name: Vet
run: go vet ./...

View file

@ -1,4 +1,4 @@
.PHONY: proto test build vet lint
.PHONY: proto test test-fast test-race build vet lint
proto:
protoc --go_out=. --go_opt=paths=source_relative proto/agent.proto
@ -6,7 +6,13 @@ proto:
mv proto/agent.pb.go pb/agent.pb.go
test:
go test -race -v ./...
go test -race -v -count=1 -timeout 60s ./...
test-fast:
go test -count=1 -timeout 60s ./...
test-short:
go test -count=1 -timeout 30s -short ./...
build:
go build -o towerops-agent .

View file

@ -26,6 +26,7 @@ var errChannelReloaded = fmt.Errorf("channel reloaded")
var joinTimeout = 10 * time.Second
var heartbeatInterval = 60 * time.Second
var channelHeartbeatInterval = 25 * time.Second
var initialRetryDelay = time.Second
const maxJobPayloadBytes = 4 << 20 // 4 MB — well above any legitimate job list
@ -40,7 +41,7 @@ type channelMsg struct {
// runAgent connects to the server and runs the event loop with reconnect.
func runAgent(ctx context.Context, wsURL, token string) {
baseURL := strings.TrimRight(wsURL, "/")
retryDelay := time.Second
retryDelay := initialRetryDelay
maxRetry := 10 * time.Second
const successfulConnectionThreshold = 30 * time.Second
@ -60,7 +61,7 @@ func runAgent(ctx context.Context, wsURL, token string) {
slog.Debug("resetting reconnect backoff after successful session",
"duration", sessionDuration,
"previous_delay", retryDelay)
retryDelay = time.Second
retryDelay = initialRetryDelay
}
if ctx.Err() != nil {
@ -68,7 +69,7 @@ func runAgent(ctx context.Context, wsURL, token string) {
}
if errors.Is(err, errRestartRequested) {
slog.Info("restart requested, reconnecting immediately")
retryDelay = time.Second
retryDelay = initialRetryDelay
continue
}
if err != nil {

View file

@ -141,7 +141,7 @@ func TestHandleMessage(t *testing.T) {
// Wait for goroutine to finish
select {
case <-snmpCh:
case <-time.After(2 * time.Second):
case <-time.After(500 * time.Millisecond):
t.Error("timed out waiting for snmp result")
}
})
@ -337,7 +337,7 @@ func TestHandleMessage(t *testing.T) {
_, _ = handleMessage(context.Background(), channelMsg{Event: "check_jobs", Payload: payload}, testPools(t), snmpCh, mtCh, credCh, monCh, checkCh, make(chan *pb.LldpTopologyResult, 1))
select {
case <-checkCh:
case <-time.After(5 * time.Second):
case <-time.After(time.Second):
t.Error("timed out waiting for check result")
}
})
@ -400,7 +400,7 @@ func TestHandleMessage(t *testing.T) {
_, _ = handleMessage(context.Background(), channelMsg{Event: "discovery_job", Payload: payload}, testPools(t), snmpCh, mtCh, credCh, monCh, checkCh, make(chan *pb.LldpTopologyResult, 1))
select {
case <-snmpCh:
case <-time.After(2 * time.Second):
case <-time.After(500 * time.Millisecond):
t.Error("timed out waiting for discovery result")
}
})
@ -431,7 +431,7 @@ func TestHandleMessage(t *testing.T) {
if result.Error != "" {
t.Errorf("unexpected error: %s", result.Error)
}
case <-time.After(2 * time.Second):
case <-time.After(500 * time.Millisecond):
t.Error("timed out waiting for backup result")
}
})
@ -560,7 +560,7 @@ func TestDispatchJob(t *testing.T) {
if result.Error == "" {
t.Error("expected error from unreachable device")
}
case <-time.After(2 * time.Second):
case <-time.After(500 * time.Millisecond):
t.Error("timed out")
}
})
@ -589,7 +589,7 @@ func TestDispatchJob(t *testing.T) {
if result.Success {
t.Error("expected failure")
}
case <-time.After(2 * time.Second):
case <-time.After(500 * time.Millisecond):
t.Error("timed out")
}
})
@ -618,7 +618,7 @@ func TestDispatchJob(t *testing.T) {
if result.Status != "success" {
t.Errorf("expected success, got %q", result.Status)
}
case <-time.After(2 * time.Second):
case <-time.After(500 * time.Millisecond):
t.Error("timed out")
}
})
@ -648,7 +648,7 @@ func TestDispatchJob(t *testing.T) {
select {
case <-snmpCh:
case <-time.After(2 * time.Second):
case <-time.After(500 * time.Millisecond):
t.Error("timed out")
}
})
@ -701,7 +701,7 @@ func TestRunSessionRejectsFailedJoin(t *testing.T) {
_, _ = conn.Write(frame)
// Keep connection open for a bit
time.Sleep(time.Second)
time.Sleep(200 * time.Millisecond)
}()
addr := ln.Addr().String()
@ -745,7 +745,7 @@ func TestRunSessionJoinTimeout(t *testing.T) {
frameBuf := make([]byte, 4096)
_, _ = conn.Read(frameBuf)
time.Sleep(5 * time.Second)
time.Sleep(2 * time.Second)
}()
addr := ln.Addr().String()
@ -932,8 +932,8 @@ func TestRunSessionCtxCancel(t *testing.T) {
if err != nil {
t.Errorf("expected nil error on ctx cancel, got: %v", err)
}
case <-time.After(5 * time.Second):
t.Error("runSession did not exit after ctx cancel")
case <-time.After(2 * time.Second):
t.Error("runSession did not exit after ctx cancel")
}
srv.close()
}
@ -1088,6 +1088,10 @@ func TestRunSessionReadErrorDuringJoin(t *testing.T) {
}
func TestRunAgentReconnectOnError(t *testing.T) {
origRetry := initialRetryDelay
defer func() { initialRetryDelay = origRetry }()
initialRetryDelay = 50 * time.Millisecond
// Server that fails first connection then succeeds, then sends restart.
// Agent should reconnect (not exit) after both error and restart.
ln, err := net.Listen("tcp", "127.0.0.1:0")
@ -1130,14 +1134,14 @@ func TestRunAgentReconnectOnError(t *testing.T) {
Ref: strPtr("1"),
})
_, _ = conn.Write(makeTextFrame(reply))
time.Sleep(50 * time.Millisecond)
time.Sleep(20 * time.Millisecond)
restart, _ := json.Marshal(channelMsg{
Topic: "agent:agent-0",
Event: "restart",
Payload: json.RawMessage(`{}`),
})
_, _ = conn.Write(makeTextFrame(restart))
time.Sleep(time.Second)
time.Sleep(50 * time.Millisecond)
_ = conn.Close()
default:
// Third connection: agent successfully reconnected after restart
@ -1146,7 +1150,7 @@ func TestRunAgentReconnectOnError(t *testing.T) {
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
done := make(chan struct{})
@ -1156,7 +1160,7 @@ func TestRunAgentReconnectOnError(t *testing.T) {
}()
// Wait for 3rd connection attempt (proves agent reconnected after both error and restart)
deadline := time.After(10 * time.Second)
deadline := time.After(5 * time.Second)
for {
if connCount.Load() >= 3 {
cancel()
@ -1166,7 +1170,7 @@ func TestRunAgentReconnectOnError(t *testing.T) {
case <-deadline:
t.Fatalf("expected at least 3 connections, got %d", connCount.Load())
default:
time.Sleep(50 * time.Millisecond)
time.Sleep(10 * time.Millisecond)
}
}
@ -1192,6 +1196,10 @@ func TestRunAgentContextCancellation(t *testing.T) {
}
func TestRunAgentRestart(t *testing.T) {
origRetry := initialRetryDelay
defer func() { initialRetryDelay = origRetry }()
initialRetryDelay = 50 * time.Millisecond
// After receiving a restart event, the agent should reconnect (not exit).
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
@ -1228,20 +1236,20 @@ func TestRunAgentRestart(t *testing.T) {
if count == 1 {
// First connection: send restart event
time.Sleep(50 * time.Millisecond)
time.Sleep(20 * time.Millisecond)
restart, _ := json.Marshal(channelMsg{
Topic: "agent:agent-0",
Event: "restart",
Payload: json.RawMessage(`{}`),
})
_, _ = conn.Write(makeTextFrame(restart))
time.Sleep(time.Second)
time.Sleep(200 * time.Millisecond)
}
_ = conn.Close()
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
done := make(chan struct{})
@ -1261,7 +1269,7 @@ func TestRunAgentRestart(t *testing.T) {
case <-deadline:
t.Fatalf("expected at least 2 connections, got %d", connCount.Load())
default:
time.Sleep(50 * time.Millisecond)
time.Sleep(10 * time.Millisecond)
}
}
@ -1401,7 +1409,7 @@ func TestRunSessionProcessesJobResults(t *testing.T) {
srv.sendEvent("check_jobs", checkPayload)
// Wait for results to flow through all channels
time.Sleep(2 * time.Second)
time.Sleep(500 * time.Millisecond)
close(stopDrain)
srv.close()
}()
@ -1447,7 +1455,7 @@ func TestRunSessionSnmpBatchThreshold(t *testing.T) {
}
srv.sendEvent("jobs", makeJobPayload(jobs...))
time.Sleep(3 * time.Second)
time.Sleep(time.Second)
close(stopDrain)
srv.close()
}()
@ -1509,10 +1517,10 @@ func TestExecuteCheckCtxDoneInClosure(t *testing.T) {
executeCheck(ctx, check, p, checkCh)
// Wait for the check to complete (TCP to port 1 fails fast)
time.Sleep(500 * time.Millisecond)
time.Sleep(20 * time.Millisecond)
// Cancel ctx so the closure's select picks ctx.Done instead of blocked channel send
cancel()
time.Sleep(100 * time.Millisecond)
time.Sleep(10 * time.Millisecond)
}
func TestRunSessionRestartInMainLoop(t *testing.T) {
@ -1523,10 +1531,10 @@ func TestRunSessionRestartInMainLoop(t *testing.T) {
stopDrain := make(chan struct{})
go drainFrames(srv.conn, stopDrain)
time.Sleep(100 * time.Millisecond)
time.Sleep(50 * time.Millisecond)
// Send restart event — exercised in the main loop select
srv.sendEvent("restart", json.RawMessage(`{}`))
time.Sleep(time.Second)
time.Sleep(200 * time.Millisecond)
close(stopDrain)
srv.close()
}()
@ -1554,7 +1562,7 @@ func TestRunSessionHeartbeats(t *testing.T) {
stopDrain := make(chan struct{})
go drainFrames(srv.conn, stopDrain)
// Let heartbeats fire a few times
time.Sleep(500 * time.Millisecond)
time.Sleep(200 * time.Millisecond)
close(stopDrain)
srv.close()
}()
@ -1566,6 +1574,10 @@ func TestRunSessionHeartbeats(t *testing.T) {
}
func TestRunAgentCancelDuringRetry(t *testing.T) {
origRetry := initialRetryDelay
defer func() { initialRetryDelay = origRetry }()
initialRetryDelay = 100 * time.Millisecond
// Connects to a port nothing listens on, then cancel during retry delay
ctx, cancel := context.WithCancel(context.Background())
@ -1576,13 +1588,13 @@ func TestRunAgentCancelDuringRetry(t *testing.T) {
}()
// Wait for first connection attempt to fail and retry delay to start
time.Sleep(1500 * time.Millisecond)
time.Sleep(150 * time.Millisecond)
cancel()
select {
case <-done:
// runAgent returned after cancel during retry
case <-time.After(5 * time.Second):
case <-time.After(time.Second):
t.Error("runAgent did not return after cancel during retry")
}
}

6
go.mod
View file

@ -4,9 +4,9 @@ go 1.25.6
require (
github.com/gosnmp/gosnmp v1.43.2
golang.org/x/crypto v0.49.0
golang.org/x/net v0.52.0
golang.org/x/crypto v0.52.0
golang.org/x/net v0.55.0
google.golang.org/protobuf v1.36.11
)
require golang.org/x/sys v0.42.0 // indirect
require golang.org/x/sys v0.45.0 // indirect

16
go.sum
View file

@ -8,14 +8,14 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View file

@ -14,7 +14,10 @@ func TestPingDeviceLocalhost(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipping ping test on windows")
}
ms, err := pingDevice("127.0.0.1", 5000)
if testing.Short() {
t.Skip("skipping real ICMP ping in short mode")
}
ms, err := pingDevice("127.0.0.1", 2000)
if err != nil {
t.Skipf("ping not available: %v", err)
}
@ -34,7 +37,10 @@ func TestPingDeviceIPv6(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipping ping test on windows")
}
ms, err := pingDevice("::1", 5000)
if testing.Short() {
t.Skip("skipping real ICMP ping in short mode")
}
ms, err := pingDevice("::1", 2000)
if err != nil {
t.Skipf("IPv6 not available: %v", err)
}
@ -44,7 +50,10 @@ func TestPingDeviceIPv6(t *testing.T) {
}
func TestIcmpPingLocalhost(t *testing.T) {
ms, err := icmpPing("127.0.0.1", 5000)
if testing.Short() {
t.Skip("skipping real ICMP ping in short mode")
}
ms, err := icmpPing("127.0.0.1", 2000)
if err != nil {
t.Skipf("ICMP not available: %v", err)
}
@ -54,7 +63,10 @@ func TestIcmpPingLocalhost(t *testing.T) {
}
func TestIcmpPingIPv6(t *testing.T) {
ms, err := icmpPing("::1", 5000)
if testing.Short() {
t.Skip("skipping real ICMP ping in short mode")
}
ms, err := icmpPing("::1", 2000)
if err != nil {
t.Skipf("IPv6 ICMP not available: %v", err)
}
@ -139,7 +151,10 @@ func TestExecPingLocalhost(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipping on windows")
}
ms, err := execPing("127.0.0.1", 5000)
if testing.Short() {
t.Skip("skipping real exec ping in short mode")
}
ms, err := execPing("127.0.0.1", 2000)
if err != nil {
t.Skipf("ping command not available: %v", err)
}
@ -159,7 +174,10 @@ func TestExecPingIPv6Localhost(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipping on windows")
}
ms, err := execPing("::1", 5000)
if testing.Short() {
t.Skip("skipping real exec ping in short mode")
}
ms, err := execPing("::1", 2000)
if err != nil {
t.Skipf("ping6 not available: %v", err)
}
@ -185,6 +203,9 @@ func TestPingDeviceFallbackToExec(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipping on windows")
}
if testing.Short() {
t.Skip("skipping real ping in short mode")
}
// Mock icmpListenPacket to always fail → forces fallback to execPing
origListen := icmpListenPacket
defer func() { icmpListenPacket = origListen }()
@ -193,7 +214,7 @@ func TestPingDeviceFallbackToExec(t *testing.T) {
return nil, fmt.Errorf("permission denied")
}
ms, err := pingDevice("127.0.0.1", 5000)
ms, err := pingDevice("127.0.0.1", 2000)
if err != nil {
t.Skipf("exec ping fallback not available: %v", err)
}
@ -288,6 +309,9 @@ func TestIcmpPingNonICMPUnavailableError(t *testing.T) {
}
func TestIcmpPingUDPFallback(t *testing.T) {
if testing.Short() {
t.Skip("skipping real ICMP in short mode")
}
// Mock raw ICMP to fail, forcing UDP fallback path in icmpPing
origListen := icmpListenPacket
defer func() { icmpListenPacket = origListen }()
@ -303,7 +327,7 @@ func TestIcmpPingUDPFallback(t *testing.T) {
return icmp.ListenPacket(network, address)
}
ms, err := icmpPing("127.0.0.1", 5000)
ms, err := icmpPing("127.0.0.1", 2000)
if err != nil {
t.Skipf("UDP ICMP not available: %v", err)
}

View file

@ -712,6 +712,10 @@ func TestWSDialMissingAcceptHeader(t *testing.T) {
}
func TestWSDialMissingUpgradeHeader(t *testing.T) {
origTimeout := wsHandshakeTimeout
defer func() { wsHandshakeTimeout = origTimeout }()
wsHandshakeTimeout = time.Second
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
@ -743,6 +747,10 @@ func TestWSDialMissingUpgradeHeader(t *testing.T) {
}
func TestWSDialRejectsNon101Status(t *testing.T) {
origTimeout := wsHandshakeTimeout
defer func() { wsHandshakeTimeout = origTimeout }()
wsHandshakeTimeout = time.Second
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
@ -774,17 +782,44 @@ func TestWSDialRejectsNon101Status(t *testing.T) {
}
func TestWSDialDefaultPorts(t *testing.T) {
// Test that ws:// defaults to port 80 — will fail to connect but verifies URL parsing
origTimeout := wsHandshakeTimeout
origDial := netDial
origTLS := tlsDial
defer func() {
wsHandshakeTimeout = origTimeout
netDial = origDial
tlsDial = origTLS
}()
wsHandshakeTimeout = time.Second
var wsCalls, wssCalls int
netDial = func(network, addr string) (net.Conn, error) {
wsCalls++
if addr == "127.0.0.1:80" {
return nil, fmt.Errorf("connection refused (mock)")
}
return nil, fmt.Errorf("mock dial: %s", addr)
}
tlsDial = func(network, addr string) (net.Conn, error) {
wssCalls++
return nil, fmt.Errorf("tls dial refused (mock)")
}
_, err := WSDial("ws://127.0.0.1/path")
if err == nil {
t.Error("expected connection error (nothing on port 80)")
}
if wsCalls == 0 {
t.Error("expected netDial to be called for ws://")
}
// Test that wss:// defaults to port 443
_, err = WSDial("wss://127.0.0.1/path")
if err == nil {
t.Error("expected connection error (nothing on port 443)")
}
if wssCalls == 0 {
t.Error("expected tlsDial to be called for wss://")
}
}
func TestWSDialRealHTTPServer(t *testing.T) {