homelabby/internal/usb/manager_test.go
Mikkel Georgsen 82eaf6bed7 feat(04-01): USB Manager goroutine-per-device, poll loop, reconnect, leak-safe teardown
- Manager with injectable enumerateFunc and serialOpener for test isolation
- goroutine-per-device model: deviceLoop owns one serial port per device
- context+done-channel teardown: inner read goroutine exits on ctx.Done()
- Read buffer capped at 4096 bytes (T-04-01 threat mitigation)
- Poll loop reconciles prev/current snapshots for connect/disconnect events
- ErrDeviceNotConnected returned by Send() when device absent
- All 5 manager tests pass with -race flag; goroutine count stable across 5 replug cycles
- mockPort test helper blocks Read until Close() unblocks it (realistic behavior)
2026-04-10 06:45:26 +00:00

215 lines
6.1 KiB
Go

package usb
import (
"context"
"errors"
"runtime"
"sync/atomic"
"testing"
"time"
)
// mockEnumerator returns predefined snapshots per call number.
// Each call to next() advances the snapshot index.
type mockEnumerator struct {
snapshots []map[string]string
idx atomic.Int32
}
func newMockEnumerator(snapshots ...map[string]string) *mockEnumerator {
return &mockEnumerator{snapshots: snapshots}
}
func (m *mockEnumerator) next() (map[string]string, error) {
i := int(m.idx.Add(1)) - 1
if i >= len(m.snapshots) {
// Return last snapshot forever after exhaustion.
i = len(m.snapshots) - 1
}
// Deep copy to prevent mutation.
out := make(map[string]string, len(m.snapshots[i]))
for k, v := range m.snapshots[i] {
out[k] = v
}
return out, nil
}
// noopSerialOpener is a mock serial opener that does nothing (no hardware needed).
// It returns a mockPort that satisfies the serialPort interface.
func noopSerialOpener(path string, baud int) (serialPort, error) {
return newMockPort(), nil
}
// --- Test 1: Start with one device → exactly 1 device goroutine spawned ---
func TestManagerStartSpawnsOneGoroutine(t *testing.T) {
enum := newMockEnumerator(
map[string]string{"0525:a4a7": "/dev/cu.mock0"}, // device present
)
m := newManagerForTest(enum.next, noopSerialOpener, 20*time.Millisecond)
baseline := runtime.NumGoroutine()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
m.Start(ctx)
time.Sleep(60 * time.Millisecond) // allow poll loop and device goroutine to start
after := runtime.NumGoroutine()
// We expect at least 1 new goroutine (the device loop) + poll loop goroutine.
// Baseline can fluctuate ±2 so check at least 1 extra goroutine appeared.
if after <= baseline {
t.Errorf("expected goroutine count to increase after Start(); baseline=%d after=%d", baseline, after)
}
m.Stop()
}
// --- Test 2: Device disconnect then reconnect emits Disconnected then Connected events ---
func TestManagerDisconnectReconnectEvents(t *testing.T) {
enum := newMockEnumerator(
map[string]string{"0525:a4a7": "/dev/cu.mock0"}, // call 1: present
map[string]string{}, // call 2: absent
map[string]string{"0525:a4a7": "/dev/cu.mock0"}, // call 3: present again
)
m := newManagerForTest(enum.next, noopSerialOpener, 20*time.Millisecond)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
m.Start(ctx)
defer m.Stop()
var events []DeviceEvent
done := make(chan struct{})
go func() {
defer close(done)
for e := range m.Events() {
events = append(events, e)
if len(events) >= 3 { // Connected, Disconnected, Connected
return
}
}
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatalf("timeout waiting for 3 events; got %d: %v", len(events), events)
}
if len(events) < 3 {
t.Fatalf("expected at least 3 events, got %d: %v", len(events), events)
}
if events[0].State != StateConnected {
t.Errorf("event[0]: expected Connected, got %v", events[0].State)
}
if events[1].State != StateDisconnected {
t.Errorf("event[1]: expected Disconnected, got %v", events[1].State)
}
if events[2].State != StateConnected {
t.Errorf("event[2]: expected Connected, got %v", events[2].State)
}
}
// --- Test 3: Stop causes all goroutines to exit within 500ms ---
func TestManagerStopGoroutineLeak(t *testing.T) {
enum := newMockEnumerator(
map[string]string{"0525:a4a7": "/dev/cu.mock0"},
)
m := newManagerForTest(enum.next, noopSerialOpener, 20*time.Millisecond)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
baseline := runtime.NumGoroutine()
m.Start(ctx)
time.Sleep(60 * time.Millisecond) // let goroutines spin up
m.Stop()
deadline := time.Now().Add(500 * time.Millisecond)
for time.Now().Before(deadline) {
current := runtime.NumGoroutine()
// Allow ±2 goroutines for runtime background goroutines.
if current <= baseline+2 {
return // passed
}
time.Sleep(10 * time.Millisecond)
}
t.Errorf("goroutine leak: baseline=%d, after Stop()=%d (expected within ±2)",
baseline, runtime.NumGoroutine())
}
// --- Test 4: Send to absent device returns ErrDeviceNotConnected ---
func TestManagerSendToAbsentDevice(t *testing.T) {
// Empty snapshot — no devices connected.
enum := newMockEnumerator(map[string]string{})
m := newManagerForTest(enum.next, noopSerialOpener, 20*time.Millisecond)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
m.Start(ctx)
defer m.Stop()
time.Sleep(40 * time.Millisecond) // let poll loop run
replyCh := make(chan error, 1)
err := m.Send("0525:a4a7", Command{
Type: CmdWrite,
Reply: replyCh,
})
if !errors.Is(err, ErrDeviceNotConnected) {
t.Errorf("expected ErrDeviceNotConnected, got %v", err)
}
}
// --- Test 5: Goroutine count stable across 5 simulated unplug/replug cycles ---
func TestGoroutineStability(t *testing.T) {
// Build snapshots: 5 cycles of present/absent/present
snapshots := []map[string]string{}
snapshots = append(snapshots, map[string]string{}) // initial empty
for i := 0; i < 5; i++ {
snapshots = append(snapshots, map[string]string{"0525:a4a7": "/dev/cu.mock0"}) // plug in
snapshots = append(snapshots, map[string]string{}) // unplug
}
// Final state: device connected
snapshots = append(snapshots, map[string]string{"0525:a4a7": "/dev/cu.mock0"})
enum := newMockEnumerator(snapshots...)
m := newManagerForTest(enum.next, noopSerialOpener, 20*time.Millisecond)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
baseline := runtime.NumGoroutine()
m.Start(ctx)
// Wait long enough for all snapshots to be polled through.
// 12 snapshots * 20ms poll interval = ~240ms minimum; give 2x buffer.
time.Sleep(500 * time.Millisecond)
m.Stop()
// Give goroutines up to 500ms to settle.
deadline := time.Now().Add(500 * time.Millisecond)
var finalCount int
for time.Now().Before(deadline) {
finalCount = runtime.NumGoroutine()
if finalCount <= baseline+2 {
return // stable
}
time.Sleep(10 * time.Millisecond)
}
t.Errorf("goroutine count unstable after 5 replug cycles: baseline=%d, final=%d (max allowed %d)",
baseline, finalCount, baseline+2)
}