package serial import ( "context" "errors" "io" "net" "reflect" "sync" "testing" "time" ) type fakeStreamResolver struct { mu sync.Mutex devices map[string]string active map[string]bool released map[string]int } func newFakeStreamResolver() *fakeStreamResolver { return &fakeStreamResolver{ devices: map[string]string{"CH01": "/dev/ttyWCH4", "CH02": "/dev/ttyWCH5"}, active: make(map[string]bool), released: make(map[string]int), } } func (r *fakeStreamResolver) ReserveStream(ctx context.Context, channel string) (*StreamTarget, error) { if err := ctx.Err(); err != nil { return nil, err } r.mu.Lock() defer r.mu.Unlock() device, ok := r.devices[channel] if !ok { return nil, &UnavailableError{Reason: "channel is not mapped"} } if r.active[channel] { return nil, ErrStreamLeaseBusy } r.active[channel] = true return newStreamTarget(channel, device, "fake-pci-group", func() { r.mu.Lock() defer r.mu.Unlock() delete(r.active, channel) r.released[channel]++ }), nil } func (r *fakeStreamResolver) releaseCount(channel string) int { r.mu.Lock() defer r.mu.Unlock() return r.released[channel] } type streamOpenFunc func(string) (io.ReadWriteCloser, error) func (f streamOpenFunc) OpenStream(path string) (io.ReadWriteCloser, error) { return f(path) } func TestStreamOpenIsSerializedWithinPCIGroup(t *testing.T) { resolver := newFakeStreamResolver() var mu sync.Mutex activeOpens := 0 maximumOpens := 0 peers := make([]io.Closer, 0, 2) manager, err := NewStreamLeaseManager(resolver, StreamLeaseOptions{ Opener: streamOpenFunc(func(string) (io.ReadWriteCloser, error) { mu.Lock() activeOpens++ if activeOpens > maximumOpens { maximumOpens = activeOpens } mu.Unlock() time.Sleep(25 * time.Millisecond) serialSide, peer := net.Pipe() mu.Lock() peers = append(peers, peer) activeOpens-- mu.Unlock() return serialSide, nil }), }) if err != nil { t.Fatal(err) } start := make(chan struct{}) type result struct { lease *StreamLease err error } results := make(chan result, 2) for _, channel := range []string{"CH01", "CH02"} { channel := channel go func() { <-start lease, acquireErr := manager.Acquire(context.Background(), channel, "sessiond-open-test-"+channel) results <- result{lease: lease, err: acquireErr} }() } close(start) for index := 0; index < 2; index++ { got := <-results if got.err != nil { t.Fatal(got.err) } if err := got.lease.Close(); err != nil { t.Fatal(err) } } for _, peer := range peers { _ = peer.Close() } if maximumOpens != 1 { t.Fatalf("same PCI group had %d concurrent opens, want 1", maximumOpens) } } func TestStreamLeaseIsExclusiveAndReleasesDevice(t *testing.T) { resolver := newFakeStreamResolver() serialSide, peer := net.Pipe() defer peer.Close() opened := make([]string, 0, 1) manager, err := NewStreamLeaseManager(resolver, StreamLeaseOptions{ Opener: streamOpenFunc(func(path string) (io.ReadWriteCloser, error) { opened = append(opened, path) return serialSide, nil }), }) if err != nil { t.Fatalf("new manager: %v", err) } lease, err := manager.Acquire(context.Background(), "CH01", "sessiond-test") if err != nil { t.Fatalf("acquire: %v", err) } if _, err := manager.Acquire(context.Background(), "CH01", "sessiond-test-2"); !errors.Is(err, ErrStreamLeaseBusy) { t.Fatalf("second acquire error = %v, want busy", err) } if !reflect.DeepEqual(opened, []string{"/dev/ttyWCH4"}) { t.Fatalf("opened = %#v", opened) } if err := lease.Close(); err != nil { t.Fatalf("close lease: %v", err) } if err := lease.Close(); err != nil { t.Fatalf("idempotent close: %v", err) } if resolver.releaseCount("CH01") != 1 { t.Fatalf("release count = %d", resolver.releaseCount("CH01")) } if got := manager.Snapshot(); got.ActiveLeases != 0 { t.Fatalf("snapshot = %#v", got) } } func TestStreamReconnectKeepsCanonicalDeviceIdentity(t *testing.T) { resolver := newFakeStreamResolver() opened := make([]string, 0, 2) manager, err := NewStreamLeaseManager(resolver, StreamLeaseOptions{ Opener: streamOpenFunc(func(path string) (io.ReadWriteCloser, error) { opened = append(opened, path) serialSide, peer := net.Pipe() _ = peer.Close() return serialSide, nil }), }) if err != nil { t.Fatal(err) } for index := 0; index < 2; index++ { lease, err := manager.Acquire(context.Background(), "CH01", "sessiond-reconnect") if err != nil { t.Fatalf("acquire %d: %v", index, err) } if err := lease.Close(); err != nil { t.Fatalf("close %d: %v", index, err) } } if !reflect.DeepEqual(opened, []string{"/dev/ttyWCH4", "/dev/ttyWCH4"}) { t.Fatalf("reconnect opened = %#v", opened) } } func TestStreamLeaseCountsBytesAndDropsOnlyMetadataWhenSinkIsFull(t *testing.T) { resolver := newFakeStreamResolver() serialSide, peer := net.Pipe() defer peer.Close() sink := NewBoundedStreamEventSink(1) manager, err := NewStreamLeaseManager(resolver, StreamLeaseOptions{ Opener: streamOpenFunc(func(string) (io.ReadWriteCloser, error) { return serialSide, nil }), Events: sink, }) if err != nil { t.Fatal(err) } lease, err := manager.Acquire(context.Background(), "CH01", "sessiond-test") if err != nil { t.Fatal(err) } defer lease.Close() written := make(chan error, 1) go func() { _, err := lease.Write([]byte{0x00, 0xff, 0x3a}) written <- err }() buf := make([]byte, 3) if _, err := io.ReadFull(peer, buf); err != nil { t.Fatal(err) } if err := <-written; err != nil { t.Fatal(err) } if !reflect.DeepEqual(buf, []byte{0x00, 0xff, 0x3a}) { t.Fatalf("payload = %v", buf) } readDone := make(chan error, 1) go func() { _, err := peer.Write([]byte{0x10, 0x00}) readDone <- err }() read := make([]byte, 2) if _, err := io.ReadFull(lease, read); err != nil { t.Fatal(err) } if err := <-readDone; err != nil { t.Fatal(err) } snapshot := manager.Snapshot() if snapshot.TXBytes != 3 || snapshot.RXBytes != 2 || sink.Dropped() == 0 { t.Fatalf("snapshot = %#v, dropped = %d", snapshot, sink.Dropped()) } if len(snapshot.Channels) != 1 || snapshot.Channels[0].Channel != "CH01" || !snapshot.Channels[0].Active || snapshot.Channels[0].TXBytes != 3 || snapshot.Channels[0].RXBytes != 2 || snapshot.Channels[0].LastRXAt == nil { t.Fatalf("channel snapshot = %#v", snapshot.Channels) } select { case event := <-sink.Events(): if event.Channel != "CH01" || event.LeaseID == "" { t.Fatalf("event = %#v", event) } case <-time.After(time.Second): t.Fatal("expected bounded metadata event") } } func TestStreamPayloadCaptureRespectsDirectionAndCopiesBytes(t *testing.T) { resolver := newFakeStreamResolver() serialSide, peer := net.Pipe() defer peer.Close() sink := NewBoundedStreamEventSink(16) manager, err := NewStreamLeaseManager(resolver, StreamLeaseOptions{ Opener: streamOpenFunc(func(string) (io.ReadWriteCloser, error) { return serialSide, nil }), Events: sink, }) if err != nil { t.Fatal(err) } if err := manager.ApplyCapture(context.Background(), []CaptureConfig{{Channel: "CH01", Mode: CaptureModeRX}}); err != nil { t.Fatal(err) } lease, err := manager.Acquire(context.Background(), "CH01", "sessiond-capture") if err != nil { t.Fatal(err) } defer lease.Close() <-sink.Events() // lifecycle open txDone := make(chan error, 1) go func() { _, err := lease.Write([]byte{0xaa}); txDone <- err }() if _, err := io.ReadFull(peer, make([]byte, 1)); err != nil { t.Fatal(err) } if err := <-txDone; err != nil { t.Fatal(err) } select { case event := <-sink.Events(): t.Fatalf("TX event bypassed RX capture policy: %#v", event) case <-time.After(20 * time.Millisecond): } rxSource := []byte{0x00, 0xff, 0x3a} rxDone := make(chan error, 1) go func() { _, err := peer.Write(rxSource); rxDone <- err }() buffer := make([]byte, len(rxSource)) if _, err := io.ReadFull(lease, buffer); err != nil { t.Fatal(err) } if err := <-rxDone; err != nil { t.Fatal(err) } buffer[0] = 0x99 event := <-sink.Events() if event.Direction != CaptureModeRX || !reflect.DeepEqual(event.Payload, rxSource) || event.Bytes != uint64(len(rxSource)) || event.Timestamp.IsZero() { t.Fatalf("captured event = %#v", event) } } func TestStreamPayloadCaptureSplitsBoundedChunks(t *testing.T) { resolver := newFakeStreamResolver() serialSide, peer := net.Pipe() defer peer.Close() sink := NewBoundedStreamEventSink(8) manager, err := NewStreamLeaseManager(resolver, StreamLeaseOptions{ Opener: streamOpenFunc(func(string) (io.ReadWriteCloser, error) { return serialSide, nil }), Events: sink, }) if err != nil { t.Fatal(err) } lease, err := manager.Acquire(context.Background(), "CH01", "sessiond-chunk") if err != nil { t.Fatal(err) } defer lease.Close() <-sink.Events() payload := make([]byte, MaxCapturedPayloadBytes+17) for index := range payload { payload[index] = byte(index) } done := make(chan error, 1) go func() { _, err := lease.Write(payload); done <- err }() if _, err := io.ReadFull(peer, make([]byte, len(payload))); err != nil { t.Fatal(err) } if err := <-done; err != nil { t.Fatal(err) } first, second := <-sink.Events(), <-sink.Events() if len(first.Payload) != MaxCapturedPayloadBytes || len(second.Payload) != 17 { t.Fatalf("chunk sizes = %d, %d", len(first.Payload), len(second.Payload)) } combined := append(append([]byte(nil), first.Payload...), second.Payload...) if !reflect.DeepEqual(combined, payload) { t.Fatal("captured chunks did not preserve payload") } } func TestStreamPayloadCaptureFramesDelimiterAndTimeoutWithDisplayPolicy(t *testing.T) { resolver := newFakeStreamResolver() serialSide, peer := net.Pipe() defer peer.Close() sink := NewBoundedStreamEventSink(8) manager, err := NewStreamLeaseManager(resolver, StreamLeaseOptions{ Opener: streamOpenFunc(func(string) (io.ReadWriteCloser, error) { return serialSide, nil }), Events: sink, }) if err != nil { t.Fatal(err) } config := CaptureConfig{ Channel: "CH01", Mode: CaptureModeTX, DataPackage: CapturePackageConfig{Mode: "delimiter", Length: 128, TimeoutMillis: 30, DelimiterProcess: "remove", Delimiter1: 13, Delimiter2: 10}, DataLog: CaptureLogConfig{ Send: true, ShowTimestamp: true, ShowDirection: true, ShowUsername: true, ShowIPAddress: true, ShowPort: true, Format: "hex", ReadTrigger: CaptureTriggerConfig{Process: "keep", Character1: -1, Character2: -1}, SendTrigger: CaptureTriggerConfig{Process: "keep", Character1: -1, Character2: -1}, }, } if err := manager.ApplyCapture(context.Background(), []CaptureConfig{config}); err != nil { t.Fatal(err) } lease, err := manager.Acquire(context.Background(), "CH01", "sessiond-framing") if err != nil { t.Fatal(err) } defer lease.Close() <-sink.Events() write := func(payload []byte) { t.Helper() done := make(chan error, 1) go func() { _, err := lease.Write(payload); done <- err }() if _, err := io.ReadFull(peer, make([]byte, len(payload))); err != nil { t.Fatal(err) } if err := <-done; err != nil { t.Fatal(err) } } write([]byte("show version\r")) select { case event := <-sink.Events(): t.Fatalf("partial delimiter emitted early: %#v", event) case <-time.After(10 * time.Millisecond): } write([]byte("\nnext")) first := <-sink.Events() if string(first.Payload) != "show version" || first.LogFormat != "hex" || !first.ShowUsername || !first.ShowIPAddress || !first.ShowPort { t.Fatalf("delimiter event = %#v", first) } select { case second := <-sink.Events(): if string(second.Payload) != "next" { t.Fatalf("timeout event = %#v", second) } case <-time.After(time.Second): t.Fatal("timed capture frame was not flushed") } } func TestMappingControllerReservationBlocksLineMutationUntilRelease(t *testing.T) { controller, _ := transactionController(t) target, err := controller.ReserveStream(context.Background(), "CH01") if err != nil { t.Fatalf("reserve: %v", err) } if target.Channel != "CH01" || target.Device != controller.byChannel["CH01"].Device { t.Fatalf("target = %#v", target) } request := []ChannelConfig{{ Channel: "CH01", BaudRate: 115200, DataBits: 8, Parity: "none", StopBits: 1, FlowControl: "none", }} if _, err := controller.Apply(context.Background(), request); !errors.Is(err, ErrStreamLeaseBusy) { t.Fatalf("apply while leased error = %v, want busy", err) } target.Release() if _, err := controller.Apply(context.Background(), request); err != nil { t.Fatalf("apply after release: %v", err) } }