- 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)
215 lines
6.1 KiB
Go
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)
|
|
}
|