docs(04-01): complete USB manager plan — goroutine-per-device, VID/PID enumeration

- 10/10 tests pass with -race flag
- goroutine count stable across 5 replug cycles
- T-04-01 read buffer cap mitigated
- Known stubs: VID/PID placeholder pending hardware 2026-04-13
This commit is contained in:
Mikkel Georgsen 2026-04-10 06:46:07 +00:00
parent 82eaf6bed7
commit 63b66f7a94

View file

@ -0,0 +1,110 @@
---
phase: 04-usb-manager-label-printing
plan: "01"
subsystem: usb
tags: [usb, serial, goroutine, enumeration, reconnect]
dependency_graph:
requires: []
provides: [internal/usb package, USBManager, DeviceSpec, DeviceEvent, Command]
affects: [internal/api/router.go (future SSE integration), main.go (future Manager.Start call)]
tech_stack:
added: [go.bug.st/serial v1.6.4, github.com/creack/goselect v0.1.2]
patterns: [goroutine-per-device, context+done-channel teardown, injectable test seams]
key_files:
created:
- internal/usb/device.go
- internal/usb/enumerate.go
- internal/usb/manager.go
- internal/usb/device_test.go
- internal/usb/manager_test.go
- internal/usb/mock_port_test.go
modified:
- go.mod
- go.sum
decisions:
- "VID/PID enumeration via system_profiler SPUSBDataType JSON + injectable sysProfilerCmd for tests; gracefully returns empty map on Linux/CI"
- "serialPort interface abstraction allows test injection via noopSerialOpener without hardware"
- "mockPort.Read() blocks on closeCh channel until Close() is called — mirrors real blocking serial Read behavior"
- "Read buffer capped at 4096 bytes per T-04-01 threat mitigation before forwarding to command handler"
- "pollInterval injected at newManagerForTest level (20ms in tests vs 2s default) for fast deterministic cycling"
metrics:
duration_minutes: 15
completed_date: "2026-04-10"
tasks_completed: 2
tasks_total: 2
files_created: 6
files_modified: 2
---
# Phase 4 Plan 01: USB Manager Summary
**One-liner:** Goroutine-per-device USB Manager with VID/PID enumeration via system_profiler, context+done-channel teardown, and injectable test seams — no hardware required for testing.
## What Was Built
### internal/usb/device.go
Core types: `DeviceSpec`, `DeviceRole`, `DeviceState`, `DeviceEvent`, `Command`, `CommandType`. `KnownDevices` registry maps `"VID:PID"` to `DeviceSpec` (PRT Qutie placeholder `0525:a4a7`). `ParseVIDPID()` helper with error on malformed input.
### internal/usb/enumerate.go
`enumerateConnected()` shells out to `system_profiler SPUSBDataType -json`, parses the USB device tree, normalises hex IDs (strips `0x` prefix, lowercases), and cross-references with `serial.GetPortsList()` to return `map[VIDPID]portPath` containing only `KnownDevices` entries. Two injectable function vars (`sysProfilerCmd`, `serialPortsListCmd`) enable hermetic unit tests. Returns empty map with no error on Linux/CI (graceful degradation when `system_profiler` is unavailable).
### internal/usb/manager.go
`Manager` owns all device goroutines. Poll loop calls `enumerateFunc()` every `pollInterval`, reconciles prev/current snapshots, spawns/stops `deviceLoop` goroutines. `deviceLoop` opens the serial port, runs an inner read goroutine (exits on `ctx.Done()` or port error), and handles `CmdWrite`/`CmdClose` commands. Goroutine teardown: child context cancelled → `port.Close()` called → read goroutine unblocked → `done` channel closed → poll loop proceeds. `ErrDeviceNotConnected` returned by `Send()` when device absent.
### Threat mitigations applied
- **T-04-01**: Read buffer capped at 4096 bytes in `deviceLoop` before forwarding to command handler.
## Commits
| Task | Commit | Description |
|------|---------|-------------|
| Task 1 | f5b1d31 | USB device types, KnownDevices registry, VID/PID enumeration |
| Task 2 | 82eaf6b | USB Manager goroutine-per-device, poll loop, reconnect, leak-safe teardown |
## Test Results
```
=== RUN TestKnownDevicesContainsPRTQutie PASS
=== RUN TestEnumerateConnectedMockOutput PASS
=== RUN TestDeviceSpecString PASS
=== RUN TestParseVIDPIDValid PASS
=== RUN TestParseVIDPIDInvalid PASS
=== RUN TestManagerStartSpawnsOneGoroutine PASS
=== RUN TestManagerDisconnectReconnectEvents PASS
=== RUN TestManagerStopGoroutineLeak PASS
=== RUN TestManagerSendToAbsentDevice PASS
=== RUN TestGoroutineStability PASS
PASS (race detector clean, 30s timeout)
```
## Deviations from Plan
### Auto-fixed Issues
None — plan executed exactly as written.
### Additional Files
`internal/usb/mock_port_test.go` was added (not listed in plan's `files_modified`). The plan referenced `newMockPort()` in the test action spec but did not create a dedicated file for it — the mock helper needed to be in a separate `_test.go` file to avoid shipping test infrastructure in the production binary.
## Known Stubs
| File | Item | Reason |
|------|------|--------|
| internal/usb/device.go | `KnownDevices["0525:a4a7"]` VID/PID placeholder | Real VID/PID unknown until hardware arrives 2026-04-13 — update after characterization |
| internal/usb/manager.go | Read bytes in `deviceLoop` are logged but not forwarded | No upstream consumer yet; Phase 4 plan 03 (printer driver) will consume read events |
## Threat Flags
None — no new network endpoints, auth paths, or schema changes introduced. USB serial I/O is local-only per T-04-03 acceptance.
## Self-Check: PASSED
- internal/usb/device.go: FOUND
- internal/usb/enumerate.go: FOUND
- internal/usb/manager.go: FOUND
- internal/usb/device_test.go: FOUND
- internal/usb/manager_test.go: FOUND
- internal/usb/mock_port_test.go: FOUND
- Commit f5b1d31: FOUND
- Commit 82eaf6b: FOUND