| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570 |
- package bwtest
- import (
- "bufio"
- "context"
- "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
- sampleCh chan LiveSample
- }
- // NewClient creates a new BwTestClient instance
- func NewClient() *BwTestClient {
- return &BwTestClient{
- sampleCh: make(chan LiveSample, 100),
- }
- }
- // 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 = c.samples
- }
- return st
- }
- // Subscribe returns a channel of live telemetry samples
- func (c *BwTestClient) Subscribe() <-chan LiveSample {
- return c.sampleCh
- }
- // 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.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, 120)
- 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()
- // If UDP, bind a local UDP socket
- var udpConn *net.UDPConn
- var clientUDPPort int
- if req.Protocol == "udp" {
- uaddr, err := net.ResolveUDPAddr("udp", ":0")
- 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
- }
- }
- // 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,
- }
- 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
- }
- 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
- maxSeqRecv atomic.Uint64
- )
- sessionHash := HashSessionID(sessionID)
- // Background worker for server feedback reports over TCP control socket
- go func() {
- for {
- 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)
- }
- }
- }()
- 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
- }
- // 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()
- limiter := NewRateLimiter(req.BandwidthLimit / int64(req.Streams))
- buf := make([]byte, req.PacketSize)
- 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 limiter != nil {
- limiter.Wait(req.PacketSize)
- }
- 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 {
- 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
- // Packet loss tracking
- lastMax := maxSeqRecv.Load()
- if hdr.Sequence > lastMax {
- if lastMax > 0 && hdr.Sequence > lastMax+1 {
- gap := int64(hdr.Sequence - lastMax - 1)
- localLostPackets.Add(gap)
- }
- maxSeqRecv.Store(hdr.Sequence)
- }
- }
- }()
- }
- } 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)
- limiter := NewRateLimiter(req.BandwidthLimit)
- for {
- select {
- case <-ctx.Done():
- return
- default:
- }
- if limiter != nil {
- limiter.Wait(len(chunk))
- }
- n, err := controlConn.Write(chunk)
- if err != nil {
- return
- }
- localTxBytes.Add(int64(n))
- }
- }()
- }
- if req.Direction == "rx" || req.Direction == "both" {
- wg.Add(1)
- go func() {
- defer wg.Done()
- buf := make([]byte, 64*1024)
- for {
- select {
- case <-ctx.Done():
- return
- default:
- }
- n, err := controlConn.Read(buf)
- if err != nil {
- return
- }
- localRxBytes.Add(int64(n))
- }
- }()
- }
- }
- // Telemetry sampling loop (every 500ms)
- sampleTicker := time.NewTicker(500 * time.Millisecond)
- defer sampleTicker.Stop()
- var lastTxBytes, lastRxBytes 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():
- // Notify remote server to stop
- stopMsg := SessionHandshake{Type: "stop", SessionID: sessionID}
- sb, _ := json.Marshal(stopMsg)
- _, _ = controlConn.Write(append(sb, '\n'))
- return
- case <-timeoutChan:
- 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 == "tx" {
- curTxBytes = localTxBytes.Load()
- curRxBytes = serverRxBytes.Load()
- curLostPackets = serverLostPackets.Load()
- } else if req.Direction == "rx" {
- curRxBytes = localRxBytes.Load()
- curLostPackets = localLostPackets.Load()
- } else { // both
- curTxBytes = localTxBytes.Load()
- curRxBytes = localRxBytes.Load()
- curLostPackets = localLostPackets.Load() + serverLostPackets.Load()
- }
- 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 := localRxPackets.Load() + localLostPackets.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{
- Timestamp: now,
- ElapsedSec: elapsedSec,
- TxBps: txBps,
- RxBps: rxBps,
- TxMbps: txMbps,
- RxMbps: rxMbps,
- TxPackets: localTxPackets.Load(),
- RxPackets: localRxPackets.Load(),
- TxBytes: curTxBytes,
- RxBytes: curRxBytes,
- LostPackets: curLostPackets,
- LossPct: lossPct,
- JitterMs: jitter,
- }
- c.mu.Lock()
- c.samples = append(c.samples, sample)
- if len(c.samples) > 200 {
- c.samples = c.samples[len(c.samples)-200:]
- }
- c.status.ElapsedSec = elapsedSec
- c.status.CurrentSample = sample
- c.mu.Unlock()
- // Non-blocking publish to subscriber channel
- select {
- case c.sampleCh <- *sample:
- default:
- }
- }
- }
- }
- func (c *BwTestClient) finalizeSummary() {
- if len(c.samples) == 0 {
- return
- }
- first := c.samples[0]
- last := c.samples[len(c.samples)-1]
- duration := last.ElapsedSec
- var sumTxMbps, sumRxMbps float64
- var peakTxMbps, peakRxMbps float64
- var sumLossPct, sumJitter float64
- for _, s := range c.samples {
- sumTxMbps += s.TxMbps
- sumRxMbps += s.RxMbps
- sumLossPct += s.LossPct
- sumJitter += s.JitterMs
- if s.TxMbps > peakTxMbps {
- peakTxMbps = s.TxMbps
- }
- if s.RxMbps > peakRxMbps {
- peakRxMbps = s.RxMbps
- }
- }
- count := float64(len(c.samples))
- avgTx := sumTxMbps / count
- avgRx := sumRxMbps / count
- avgLoss := sumLossPct / count
- avgJitter := sumJitter / count
- c.status.Summary = &TestSummary{
- DurationSec: duration,
- StartTime: c.status.StartTime,
- EndTime: time.Now(),
- TotalTxBytes: last.TxBytes,
- TotalRxBytes: last.RxBytes,
- AvgTxMbps: avgTx,
- AvgRxMbps: avgRx,
- PeakTxMbps: peakTxMbps,
- PeakRxMbps: peakRxMbps,
- TotalTxPackets: last.TxPackets,
- TotalRxPackets: last.RxPackets,
- TotalLostPackets: last.LostPackets,
- AvgLossPct: avgLoss,
- AvgJitterMs: avgJitter,
- }
- _ = first
- }
|