| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662 |
- package bwtest
- import (
- "bufio"
- "context"
- crand "crypto/rand"
- "encoding/json"
- "errors"
- "fmt"
- "math/rand"
- "net"
- "sync"
- "sync/atomic"
- "time"
- )
- var (
- ErrTestAlreadyRunning = errors.New("a bandwidth test is already in progress")
- ErrServerRejected = errors.New("remote server rejected bandwidth test request")
- )
- // BwTestClient manages executing bandwidth tests against a remote BwTest server
- type BwTestClient struct {
- mu sync.RWMutex
- running bool
- currentCancel context.CancelFunc
- status TestStatus
- samples []*LiveSample
- subscribers map[chan LiveSample]struct{}
- sampleCount uint64
- jitterSum, peakTx, peakRx float64
- }
- // NewClient creates a new BwTestClient instance
- func NewClient() *BwTestClient {
- return &BwTestClient{
- subscribers: make(map[chan LiveSample]struct{}),
- }
- }
- // GetStatus returns the current or most recent test state
- func (c *BwTestClient) GetStatus() TestStatus {
- c.mu.RLock()
- defer c.mu.RUnlock()
- st := c.status
- if len(c.samples) > 0 {
- st.CurrentSample = c.samples[len(c.samples)-1]
- st.RecentSamples = append([]*LiveSample(nil), c.samples...)
- }
- return st
- }
- // Subscribe gives each observer its own bounded queue. Slow observers never
- // block the data path; they can resynchronize using GetStatus.
- func (c *BwTestClient) Subscribe() (<-chan LiveSample, func()) {
- c.mu.Lock()
- ch := make(chan LiveSample, 16)
- c.subscribers[ch] = struct{}{}
- c.mu.Unlock()
- var once sync.Once
- return ch, func() { once.Do(func() { c.mu.Lock(); delete(c.subscribers, ch); close(ch); c.mu.Unlock() }) }
- }
- func (c *BwTestClient) recordSampleLocked(sample *LiveSample) {
- c.sampleCount++
- c.jitterSum += sample.JitterMs
- c.peakTx = max(c.peakTx, sample.TxMbps)
- c.peakRx = max(c.peakRx, sample.RxMbps)
- c.samples = append(c.samples, sample)
- if len(c.samples) > 200 {
- c.samples = c.samples[len(c.samples)-200:]
- }
- c.status.ElapsedSec = sample.ElapsedSec
- c.status.CurrentSample = sample
- for ch := range c.subscribers {
- select {
- case ch <- *sample:
- default:
- }
- }
- }
- // Stop terminates any currently active bandwidth test
- func (c *BwTestClient) Stop() {
- c.mu.Lock()
- defer c.mu.Unlock()
- if c.running && c.currentCancel != nil {
- c.currentCancel()
- }
- }
- // Run executes a bandwidth test asynchronously
- func (c *BwTestClient) Run(ctx context.Context, req SessionRequest) error {
- c.mu.Lock()
- if c.running {
- c.mu.Unlock()
- return ErrTestAlreadyRunning
- }
- if req.Target == "" {
- req.Target = "127.0.0.1"
- }
- if req.Port <= 0 {
- req.Port = 2000
- }
- if req.Protocol != "tcp" {
- req.Protocol = "udp"
- }
- if req.Direction != "tx" && req.Direction != "rx" && req.Direction != "both" {
- req.Direction = "both"
- }
- if req.PacketSize <= 0 {
- req.PacketSize = 1472
- }
- if req.PacketSize < 64 || req.PacketSize > 65507 {
- c.mu.Unlock()
- return fmt.Errorf("packet size must be between 64 and 65507 bytes")
- }
- if req.Streams <= 0 {
- req.Streams = 1
- }
- if req.Streams > 8 {
- req.Streams = 8
- }
- if req.Duration < 0 {
- req.Duration = 10
- }
- testCtx, cancel := context.WithCancel(context.Background())
- c.currentCancel = cancel
- c.running = true
- c.samples = make([]*LiveSample, 0, 200)
- c.sampleCount, c.jitterSum, c.peakTx, c.peakRx = 0, 0, 0, 0
- sessionID := fmt.Sprintf("bw-%d-%d", time.Now().Unix(), rand.Intn(10000))
- c.status = TestStatus{
- Running: true,
- SessionID: sessionID,
- Target: req.Target,
- Port: req.Port,
- Protocol: req.Protocol,
- Direction: req.Direction,
- BandwidthLimit: req.BandwidthLimit,
- PacketSize: req.PacketSize,
- Streams: req.Streams,
- Duration: req.Duration,
- StartTime: time.Now(),
- }
- c.mu.Unlock()
- go c.executeTest(testCtx, cancel, req, sessionID)
- return nil
- }
- func (c *BwTestClient) executeTest(ctx context.Context, cancel context.CancelFunc, req SessionRequest, sessionID string) {
- defer func() {
- cancel()
- c.mu.Lock()
- c.running = false
- c.status.Running = false
- c.finalizeSummary()
- c.mu.Unlock()
- }()
- targetAddr := fmt.Sprintf("%s:%d", req.Target, req.Port)
- dialer := net.Dialer{Timeout: 5 * time.Second}
- controlConn, err := dialer.DialContext(ctx, "tcp", targetAddr)
- if err != nil {
- c.mu.Lock()
- c.status.Error = fmt.Sprintf("Failed to connect to control port %s: %v", targetAddr, err)
- c.mu.Unlock()
- return
- }
- defer controlConn.Close()
- stopClose := context.AfterFunc(ctx, func() { controlConn.Close() })
- defer stopClose()
- _ = controlConn.SetDeadline(time.Now().Add(5 * time.Second))
- // If UDP, bind a local UDP socket
- var udpConn *net.UDPConn
- var clientUDPPort int
- if req.Protocol == "udp" {
- local := controlConn.LocalAddr().(*net.TCPAddr)
- uaddr := &net.UDPAddr{IP: local.IP, Zone: local.Zone}
- var err error
- if err == nil {
- udpConn, err = net.ListenUDP("udp", uaddr)
- }
- if err != nil {
- c.mu.Lock()
- c.status.Error = fmt.Sprintf("Failed to create local UDP socket: %v", err)
- c.mu.Unlock()
- return
- }
- defer udpConn.Close()
- _ = udpConn.SetReadBuffer(8 * 1024 * 1024)
- if la, ok := udpConn.LocalAddr().(*net.UDPAddr); ok {
- clientUDPPort = la.Port
- }
- }
- if req.Protocol == "udp" {
- size, err := udpPayloadSize(req.PacketSize, controlConn.RemoteAddr().(*net.TCPAddr).IP)
- if err != nil {
- c.mu.Lock()
- c.status.Error = err.Error()
- c.mu.Unlock()
- return
- }
- req.PacketSize = size
- }
- // Send handshake request
- hs := SessionHandshake{
- Type: "request",
- SessionID: sessionID,
- Protocol: req.Protocol,
- Direction: req.Direction,
- BandwidthLimit: req.BandwidthLimit,
- PacketSize: req.PacketSize,
- Streams: req.Streams,
- Duration: req.Duration,
- UDPPort: clientUDPPort,
- }
- if req.Protocol == "tcp" {
- hs.DataFraming = tcpFramingVersion
- } else {
- hs.UDPFeedback = udpFeedbackVersion
- }
- b, _ := json.Marshal(hs)
- if _, err := controlConn.Write(append(b, '\n')); err != nil {
- c.mu.Lock()
- c.status.Error = fmt.Sprintf("Handshake send failed: %v", err)
- c.mu.Unlock()
- return
- }
- // Read accept response
- reader := bufio.NewReader(controlConn)
- line, err := reader.ReadBytes('\n')
- if err != nil {
- c.mu.Lock()
- c.status.Error = fmt.Sprintf("Server response error: %v", err)
- c.mu.Unlock()
- return
- }
- var acceptResp SessionHandshake
- if err := json.Unmarshal(line, &acceptResp); err != nil || acceptResp.Type != "accept" {
- c.mu.Lock()
- c.status.Error = fmt.Sprintf("Server rejected test: %s", acceptResp.Message)
- c.mu.Unlock()
- return
- }
- if req.Protocol == "tcp" && acceptResp.DataFraming != tcpFramingVersion {
- c.mu.Lock()
- c.status.Error = "Server does not support framed TCP bandwidth tests; upgrade both peers"
- c.mu.Unlock()
- return
- }
- if req.Protocol == "udp" {
- if req.BandwidthLimit <= 0 && acceptResp.UDPFeedback != udpFeedbackVersion {
- c.mu.Lock()
- c.status.Error = "Automatic UDP tests require receiver-feedback support on both peers"
- c.mu.Unlock()
- return
- }
- if acceptResp.PacketSize >= 64 {
- req.PacketSize = min(req.PacketSize, acceptResp.PacketSize)
- }
- c.mu.Lock()
- c.status.PacketSize = req.PacketSize
- c.mu.Unlock()
- }
- _ = controlConn.SetDeadline(time.Time{})
- tcpWriter := &tcpFrameWriter{conn: controlConn}
- serverUDPPort := acceptResp.UDPPort
- if serverUDPPort <= 0 {
- serverUDPPort = req.Port
- }
- // Statistics counters
- var (
- localTxBytes atomic.Int64
- localTxPackets atomic.Int64
- localRxBytes atomic.Int64
- localRxPackets atomic.Int64
- localLostPackets atomic.Int64
- serverRxBytes atomic.Int64
- serverRxPackets atomic.Int64
- serverLostPackets atomic.Int64
- curJitterMs atomic.Uint64 // float64 in bits
- jitterMu sync.Mutex
- jitterCalc JitterCalculator
- rxTracker udpTracker
- )
- sessionHash := HashSessionID(sessionID)
- udpPacing := newUDPPacer(req.BandwidthLimit)
- var serverUDPAddr *net.UDPAddr
- // One reader owns the buffered connection, including bytes prefetched
- // during the handshake. It dispatches data frames and feedback reports.
- go func() {
- buf := make([]byte, tcpFrameMax)
- for {
- var line []byte
- if req.Protocol == "tcp" {
- kind, payload, err := readTCPFrame(reader, buf)
- if err != nil {
- return
- }
- if kind == tcpFrameData {
- localRxBytes.Add(int64(len(payload)))
- localRxPackets.Add(1)
- continue
- }
- line = payload
- } else {
- var err error
- line, err = reader.ReadBytes('\n')
- if err != nil {
- return
- }
- }
- var rep SessionHandshake
- if err := json.Unmarshal(line, &rep); err == nil && rep.Type == "report" {
- serverRxBytes.Store(rep.RxBytes)
- serverRxPackets.Store(rep.RxPackets)
- serverLostPackets.Store(rep.LostPackets)
- udpPacing.observe(rep.RxBytes, time.Now())
- }
- }
- }()
- var wg sync.WaitGroup
- // UDP Data path
- if req.Protocol == "udp" {
- serverUDPAddr, err = net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", req.Target, serverUDPPort))
- if err != nil {
- c.mu.Lock()
- c.status.Error = fmt.Sprintf("Failed to resolve server UDP address: %v", err)
- c.mu.Unlock()
- return
- }
- // RX-only also sends a probe, so the server learns the real UDP tuple.
- if _, err := udpConn.WriteToUDP(udpProbe(sessionHash), serverUDPAddr); err != nil {
- c.mu.Lock()
- c.status.Error = err.Error()
- c.mu.Unlock()
- return
- }
- // Transmitter (TX or Both)
- if req.Direction == "tx" || req.Direction == "both" {
- for s := 0; s < req.Streams; s++ {
- wg.Add(1)
- streamID := uint8(s)
- go func() {
- defer wg.Done()
- buf := make([]byte, req.PacketSize)
- if _, err := crand.Read(buf); err != nil {
- return
- }
- var seq uint64
- hdr := PacketHeader{
- Magic: MagicBytes,
- Version: ProtoVer,
- PacketType: PacketTypeData,
- StreamID: streamID,
- SessionHash: sessionHash,
- PayloadLength: uint32(req.PacketSize - HeaderSize),
- }
- for {
- select {
- case <-ctx.Done():
- return
- default:
- }
- if !udpPacing.wait(ctx, req.PacketSize) {
- return
- }
- seq++
- hdr.Sequence = seq
- hdr.TimestampNano = time.Now().UnixNano()
- EncodeHeader(buf, hdr)
- n, err := udpConn.WriteToUDP(buf, serverUDPAddr)
- if err == nil {
- localTxBytes.Add(int64(n))
- localTxPackets.Add(1)
- }
- }
- }()
- }
- }
- // Receiver (RX or Both)
- if req.Direction == "rx" || req.Direction == "both" {
- wg.Add(1)
- go func() {
- defer wg.Done()
- rxBuf := make([]byte, 65535)
- for {
- select {
- case <-ctx.Done():
- return
- default:
- }
- n, _, err := udpConn.ReadFromUDP(rxBuf)
- if err != nil {
- select {
- case <-ctx.Done():
- return
- default:
- time.Sleep(10 * time.Millisecond)
- continue
- }
- }
- if n < HeaderSize {
- continue
- }
- hdr, err := DecodeHeader(rxBuf[:n])
- if err != nil || hdr.SessionHash != sessionHash || hdr.Version != ProtoVer || hdr.PacketType != PacketTypeData || int(hdr.PayloadLength) != n-HeaderSize {
- continue
- }
- accepted, lost := rxTracker.accept(hdr.StreamID, hdr.Sequence)
- localLostPackets.Store(lost)
- if !accepted {
- continue
- }
- nowNano := time.Now().UnixNano()
- localRxBytes.Add(int64(n))
- localRxPackets.Add(1)
- // Jitter calculation
- jitterMu.Lock()
- j := jitterCalc.Update(hdr.TimestampNano, nowNano)
- jitterMu.Unlock()
- curJitterMs.Store(uint64(j * 1000)) // ms * 1000
- }
- }()
- }
- } else if req.Protocol == "tcp" {
- // TCP Data path
- if req.Direction == "tx" || req.Direction == "both" {
- wg.Add(1)
- go func() {
- defer wg.Done()
- chunk := make([]byte, 64*1024)
- if _, err := crand.Read(chunk); err != nil {
- return
- }
- limiter := NewRateLimiter(req.BandwidthLimit)
- for {
- select {
- case <-ctx.Done():
- return
- default:
- }
- if limiter != nil {
- if !limiter.WaitContext(ctx, len(chunk)) {
- return
- }
- }
- n, err := tcpWriter.write(tcpFrameData, chunk)
- if err != nil {
- return
- }
- localTxBytes.Add(int64(n))
- localTxPackets.Add(1)
- }
- }()
- }
- }
- // Telemetry sampling loop (every 500ms)
- sampleTicker := time.NewTicker(500 * time.Millisecond)
- defer sampleTicker.Stop()
- var lastTxBytes, lastRxBytes, lastOfferedBytes int64
- lastTime := time.Now()
- startTime := lastTime
- timeoutChan := make(<-chan time.Time)
- if req.Duration > 0 {
- timer := time.NewTimer(time.Duration(req.Duration) * time.Second)
- defer timer.Stop()
- timeoutChan = timer.C
- }
- for {
- select {
- case <-ctx.Done():
- if req.Protocol == "tcp" {
- return
- }
- // Notify remote server to stop
- stopMsg := SessionHandshake{Type: "stop", SessionID: sessionID}
- sb, _ := json.Marshal(stopMsg)
- _, _ = controlConn.Write(append(sb, '\n'))
- return
- case <-timeoutChan:
- if req.Protocol == "tcp" {
- return
- }
- stopMsg := SessionHandshake{Type: "stop", SessionID: sessionID}
- sb, _ := json.Marshal(stopMsg)
- _, _ = controlConn.Write(append(sb, '\n'))
- return
- case now := <-sampleTicker.C:
- intervalSec := now.Sub(lastTime).Seconds()
- if intervalSec <= 0 {
- intervalSec = 0.5
- }
- lastTime = now
- elapsedSec := now.Sub(startTime).Seconds()
- var curTxBytes, curRxBytes int64
- var curLostPackets int64
- if req.Direction != "rx" {
- curTxBytes = serverRxBytes.Load()
- curLostPackets += serverLostPackets.Load()
- }
- if req.Direction != "tx" {
- curRxBytes = localRxBytes.Load()
- curLostPackets += localLostPackets.Load()
- }
- offered := localTxBytes.Load()
- offeredMbps := float64((offered-lastOfferedBytes)*8) / intervalSec / 1e6
- lastOfferedBytes = offered
- if req.Protocol == "udp" && req.Direction != "tx" {
- rep := SessionHandshake{Type: "report", SessionID: sessionID, RxBytes: localRxBytes.Load(), RxPackets: localRxPackets.Load(), LostPackets: localLostPackets.Load()}
- data, _ := json.Marshal(rep)
- _ = controlConn.SetWriteDeadline(time.Now().Add(2 * time.Second))
- if _, err := controlConn.Write(append(data, '\n')); err != nil {
- c.mu.Lock()
- c.status.Error = fmt.Sprintf("Receiver feedback failed: %v", err)
- c.mu.Unlock()
- return
- }
- // Retry discovery while no reverse packet has arrived.
- if localRxPackets.Load() == 0 {
- _, _ = udpConn.WriteToUDP(udpProbe(sessionHash), serverUDPAddr)
- }
- }
- deltaTx := curTxBytes - lastTxBytes
- deltaRx := curRxBytes - lastRxBytes
- lastTxBytes = curTxBytes
- lastRxBytes = curRxBytes
- txBps := int64(float64(deltaTx*8) / intervalSec)
- rxBps := int64(float64(deltaRx*8) / intervalSec)
- txMbps := float64(txBps) / 1e6
- rxMbps := float64(rxBps) / 1e6
- // Total packets & loss %
- totalExpectedPkts := int64(0)
- if req.Direction != "tx" {
- totalExpectedPkts += localRxPackets.Load() + localLostPackets.Load()
- }
- if req.Direction != "rx" {
- totalExpectedPkts += serverRxPackets.Load() + serverLostPackets.Load()
- }
- var lossPct float64
- if totalExpectedPkts > 0 {
- lossPct = (float64(curLostPackets) / float64(totalExpectedPkts)) * 100.0
- if lossPct > 100.0 {
- lossPct = 100.0
- }
- }
- jitter := float64(curJitterMs.Load()) / 1000.0
- sample := &LiveSample{
- PacketSize: req.PacketSize,
- OfferedTxMbps: offeredMbps, OfferedTxBytes: offered,
- Timestamp: now,
- ElapsedSec: elapsedSec,
- TxBps: txBps,
- RxBps: rxBps,
- TxMbps: txMbps,
- RxMbps: rxMbps,
- TxPackets: serverRxPackets.Load(),
- RxPackets: localRxPackets.Load(),
- TxBytes: curTxBytes,
- RxBytes: curRxBytes,
- LostPackets: curLostPackets,
- LossPct: lossPct,
- JitterMs: jitter,
- }
- c.mu.Lock()
- c.recordSampleLocked(sample)
- c.mu.Unlock()
- }
- }
- }
- func (c *BwTestClient) finalizeSummary() {
- if len(c.samples) == 0 {
- return
- }
- last := c.samples[len(c.samples)-1]
- duration := last.ElapsedSec
- var avgTx, avgRx, avgLoss, avgJitter float64
- if duration > 0 {
- avgTx = float64(last.TxBytes) * 8 / duration / 1e6
- avgRx = float64(last.RxBytes) * 8 / duration / 1e6
- }
- expected := last.TxPackets + last.RxPackets + last.LostPackets
- if expected > 0 {
- avgLoss = float64(last.LostPackets) * 100 / float64(expected)
- }
- if c.sampleCount > 0 {
- avgJitter = c.jitterSum / float64(c.sampleCount)
- }
- c.status.Summary = &TestSummary{
- DurationSec: duration,
- StartTime: c.status.StartTime,
- EndTime: time.Now(),
- TotalTxBytes: last.TxBytes,
- TotalRxBytes: last.RxBytes,
- AvgTxMbps: avgTx,
- AvgRxMbps: avgRx,
- PeakTxMbps: c.peakTx,
- PeakRxMbps: c.peakRx,
- TotalTxPackets: last.TxPackets,
- TotalRxPackets: last.RxPackets,
- TotalLostPackets: last.LostPackets,
- AvgLossPct: avgLoss,
- AvgJitterMs: avgJitter,
- }
- }
|