├── .gitignore ├── .travis.yml ├── benchmark ├── client.go ├── msgpack_codec.go ├── conversion.go ├── standard_server_adapter.go ├── stat.go ├── protobuf_codec.go ├── binary_server_adapter.go ├── phoenix_server_adapter.go ├── result_recorder.go ├── connect_benchmark.go ├── pusher_server_connect_adapter.go ├── action_cable_server_connect_adapter.go ├── remote_client.go ├── pusher_common.go ├── pusher_server_adapter.go ├── worker.go ├── anycable_pusher_adapter.go ├── action_cable_server_adapter.go ├── benchmark.go └── local_client.go ├── go.mod ├── .goreleaser.yml ├── etc └── chart.rb ├── action_cable └── action_cable.pb.go ├── README.md ├── go.sum └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | 3 | tmp/ 4 | dist/ 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | sudo: required 3 | language: go 4 | go: '1.16.x' 5 | 6 | addons: 7 | apt: 8 | packages: 9 | - rpm 10 | 11 | script: skip 12 | 13 | deploy: 14 | - provider: script 15 | skip_cleanup: true 16 | script: curl -sL https://git.io/goreleaser | bash 17 | on: 18 | tags: true 19 | 20 | notifications: 21 | email: false 22 | -------------------------------------------------------------------------------- /benchmark/client.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | const ( 8 | ClientEchoCmd = iota 9 | ClientBroadcastCmd 10 | ) 11 | 12 | type Client interface { 13 | SendEcho() error 14 | SendBroadcast() error 15 | ResetRxBroadcastCount() (int, error) 16 | } 17 | 18 | type ClientPool interface { 19 | New( 20 | id int, 21 | dest, origin, serverType string, 22 | rttResultChan chan time.Duration, 23 | errChan chan error, 24 | padding []byte, 25 | ) (Client, error) 26 | Close() error 27 | } 28 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/anycable/websocket-bench 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 7 | github.com/cheggaaa/pb/v3 v3.0.1 8 | github.com/go-redis/redis/v8 v8.11.5 9 | github.com/golang/protobuf v1.5.2 10 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 11 | github.com/pusher/pusher-http-go/v5 v5.1.1 12 | github.com/spf13/cobra v0.0.3 13 | github.com/spf13/pflag v1.0.3 // indirect 14 | github.com/vmihailenco/msgpack/v5 v5.3.2 15 | golang.org/x/net v0.0.0-20210428140749-89ef3d95e781 16 | ) 17 | -------------------------------------------------------------------------------- /benchmark/msgpack_codec.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "bytes" 5 | 6 | "golang.org/x/net/websocket" 7 | 8 | "github.com/vmihailenco/msgpack/v5" 9 | ) 10 | 11 | func msgPackMarshal(v interface{}) (msg []byte, payloadType byte, err error) { 12 | var buf bytes.Buffer 13 | enc := msgpack.NewEncoder(&buf) 14 | enc.SetCustomStructTag("json") 15 | 16 | err = enc.Encode(v) 17 | 18 | return buf.Bytes(), websocket.BinaryFrame, err 19 | } 20 | 21 | func msgPackUnmarshal(msg []byte, payloadType byte, v interface{}) (err error) { 22 | dec := msgpack.NewDecoder(bytes.NewReader(msg)) 23 | dec.SetCustomStructTag("json") 24 | 25 | err = dec.Decode(v) 26 | 27 | return err 28 | } 29 | 30 | var MsgPackCodec = websocket.Codec{Marshal: msgPackMarshal, Unmarshal: msgPackUnmarshal} 31 | -------------------------------------------------------------------------------- /benchmark/conversion.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | func ParseMessageType(s string) (byte, error) { 11 | switch s { 12 | case "echo": 13 | return MsgServerEcho, nil 14 | case "broadcast": 15 | return MsgServerBroadcast, nil 16 | case "broadcastResult": 17 | return MsgServerBroadcastResult, nil 18 | default: 19 | return 0, fmt.Errorf("unknown message %s", s) 20 | } 21 | } 22 | 23 | func payloadTojsonPayload(payload *Payload) *jsonPayload { 24 | sendTime := strconv.FormatInt(payload.SendTime.UnixNano(), 10) 25 | paddingValues := strings.Split(string(payload.Padding), "0") 26 | 27 | padding := make(map[string]interface{}) 28 | 29 | for ind, val := range paddingValues { 30 | padding[string(ind)] = val 31 | } 32 | 33 | return &jsonPayload{SendTime: sendTime, Padding: padding} 34 | } 35 | 36 | func stringToBinaryPayload(strSendTime, strPadding string) (*Payload, error) { 37 | var payload Payload 38 | 39 | unixNanosecond, err := strconv.ParseInt(strSendTime, 10, 64) 40 | if err != nil { 41 | return nil, err 42 | } 43 | payload.SendTime = time.Unix(0, unixNanosecond) 44 | 45 | if len(strPadding) > 0 { 46 | payload.Padding = []byte(strPadding) 47 | } 48 | 49 | return &payload, nil 50 | } 51 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go generate ./... 4 | builds: 5 | - env: 6 | - CGO_ENABLED=0 7 | - GO111MODULE=1 8 | goos: 9 | - linux 10 | - darwin 11 | archives: 12 | - 13 | replacements: 14 | darwin: MacOS 15 | linux: Linux 16 | 386: i386 17 | amd64: x86_64 18 | checksum: 19 | name_template: '{{ .ProjectName }}_checksums.txt' 20 | snapshot: 21 | name_template: "{{ .Tag }}" 22 | changelog: 23 | sort: asc 24 | filters: 25 | exclude: 26 | - '^docs:' 27 | - '^test:' 28 | 29 | brews: 30 | - 31 | github: 32 | owner: anycable 33 | name: homebrew-websocket-bench 34 | homepage: "https://github.com/anycable/websocket-bench" 35 | description: "CLI interface for benchmark AnyCable" 36 | folder: Formula 37 | test: | 38 | system "#{bin}/websocket-bench -h" 39 | 40 | nfpms: 41 | - 42 | file_name_template: '{{ .ProjectName }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' 43 | homepage: "https://github.com/anycable/websocket-bench" 44 | description: CLI interface for benchmark AnyCable 45 | maintainer: Alexander Abroskin 46 | license: MIT 47 | vendor: Arkweid 48 | formats: 49 | - deb 50 | - rpm 51 | recommends: 52 | - rpm 53 | -------------------------------------------------------------------------------- /benchmark/standard_server_adapter.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "golang.org/x/net/websocket" 7 | ) 8 | 9 | type StandardServerAdapter struct { 10 | conn *websocket.Conn 11 | } 12 | 13 | type ssaMsg struct { 14 | Type string `json:"type"` 15 | Payload *jsonPayload `json:"payload"` 16 | } 17 | 18 | func (ssa *StandardServerAdapter) SendEcho(payload *Payload) error { 19 | return websocket.JSON.Send(ssa.conn, &ssaMsg{Type: "echo", Payload: payloadTojsonPayload(payload)}) 20 | } 21 | 22 | func (ssa *StandardServerAdapter) SendBroadcast(payload *Payload) error { 23 | return websocket.JSON.Send(ssa.conn, &ssaMsg{Type: "broadcast", Payload: payloadTojsonPayload(payload)}) 24 | } 25 | 26 | func (ssa *StandardServerAdapter) Receive() (*serverSentMsg, error) { 27 | var jsonMsg jsonServerSentMsg 28 | err := websocket.JSON.Receive(ssa.conn, &jsonMsg) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | var msg serverSentMsg 34 | msg.Type, err = ParseMessageType(jsonMsg.Type) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | payloadStr, _ := json.Marshal(jsonMsg.Payload.Padding) 40 | 41 | msg.Payload, err = stringToBinaryPayload(jsonMsg.Payload.SendTime, string(payloadStr)) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | return &msg, nil 47 | } 48 | -------------------------------------------------------------------------------- /benchmark/stat.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "sort" 5 | "time" 6 | ) 7 | 8 | type rttAggregate struct { 9 | samples []time.Duration 10 | sorted bool 11 | } 12 | 13 | type byAsc []time.Duration 14 | 15 | func (a byAsc) Len() int { return len(a) } 16 | func (a byAsc) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 17 | func (a byAsc) Less(i, j int) bool { return a[i] < a[j] } 18 | 19 | func (agg *rttAggregate) Add(rtt time.Duration) { 20 | agg.samples = append(agg.samples, rtt) 21 | agg.sorted = false 22 | } 23 | 24 | func (agg *rttAggregate) Count() int { 25 | return len(agg.samples) 26 | } 27 | 28 | func (agg *rttAggregate) Min() time.Duration { 29 | if agg.Count() == 0 { 30 | return 0 31 | } 32 | agg.Sort() 33 | return agg.samples[0] 34 | } 35 | 36 | func (agg *rttAggregate) Max() time.Duration { 37 | if agg.Count() == 0 { 38 | return 0 39 | } 40 | agg.Sort() 41 | return agg.samples[len(agg.samples)-1] 42 | } 43 | 44 | func (agg *rttAggregate) Percentile(p int) time.Duration { 45 | if p <= 0 { 46 | panic("p must be greater than 0") 47 | } else if 100 <= p { 48 | panic("p must be less 100") 49 | } 50 | 51 | agg.Sort() 52 | 53 | rank := p * len(agg.samples) / 100 54 | 55 | if agg.Count() == 0 { 56 | return 0 57 | } 58 | 59 | return agg.samples[rank] 60 | } 61 | 62 | func (agg *rttAggregate) Sort() { 63 | if agg.sorted { 64 | return 65 | } 66 | sort.Sort(byAsc(agg.samples)) 67 | agg.sorted = true 68 | } 69 | -------------------------------------------------------------------------------- /benchmark/protobuf_codec.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/golang/protobuf/proto" 8 | "golang.org/x/net/websocket" 9 | 10 | "github.com/anycable/websocket-bench/action_cable" 11 | "github.com/vmihailenco/msgpack/v5" 12 | ) 13 | 14 | func protobufMarshal(v interface{}) (msg []byte, payloadType byte, err error) { 15 | data, ok := v.(*acsaMsg) 16 | if !ok { 17 | return nil, 0, errors.New("Unsupported message struct") 18 | } 19 | 20 | buf := action_cable.Message{} 21 | buf.Identifier = data.Identifier 22 | buf.Data = data.Data 23 | buf.Command = action_cable.Command(action_cable.Command_value[data.Command]) 24 | 25 | b, err := proto.Marshal(&buf) 26 | 27 | if err != nil { 28 | return nil, 0, fmt.Errorf("Failed to marshal protobuf: %v. Error: %v", buf, err) 29 | } 30 | 31 | return b, websocket.BinaryFrame, err 32 | } 33 | 34 | func protobufUnmarshal(msg []byte, payloadType byte, v interface{}) (err error) { 35 | data, ok := v.(*acsaMsg) 36 | if !ok { 37 | return errors.New("Unsupported message struct") 38 | } 39 | 40 | buf := action_cable.Message{} 41 | 42 | err = proto.Unmarshal(msg, &buf) 43 | 44 | if err != nil { 45 | return fmt.Errorf("Failed to unmarshal protobuf: %v. Error: %v", buf, err) 46 | } 47 | 48 | data.Identifier = buf.Identifier 49 | data.Type = buf.Type.String() 50 | 51 | if buf.Message != nil { 52 | var payload interface{} 53 | err = msgpack.Unmarshal(buf.Message, &payload) 54 | 55 | if err != nil { 56 | return 57 | } 58 | 59 | data.Message = payload 60 | } 61 | 62 | return err 63 | } 64 | 65 | var ProtoBufCodec = websocket.Codec{Marshal: protobufMarshal, Unmarshal: protobufUnmarshal} 66 | -------------------------------------------------------------------------------- /benchmark/binary_server_adapter.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "encoding/binary" 5 | "golang.org/x/net/websocket" 6 | "time" 7 | ) 8 | 9 | type BinaryServerAdapter struct { 10 | conn *websocket.Conn 11 | } 12 | 13 | func payloadToBinaryMsg(msgType byte, payload *Payload) []byte { 14 | // message type - byte (1 byte) 15 | // payload size - int32 (4 bytes) 16 | // time sent - int64 (8 bytes) 17 | // padding - []byte (varies) 18 | payloadSize := 8 + len(payload.Padding) 19 | size := 1 + 4 + payloadSize 20 | buf := make([]byte, size) 21 | buf[0] = msgType 22 | binary.BigEndian.PutUint32(buf[1:5], uint32(payloadSize)) 23 | binary.BigEndian.PutUint64(buf[5:13], uint64(payload.SendTime.UnixNano())) 24 | copy(buf[13:], payload.Padding) 25 | return buf 26 | } 27 | 28 | func (bsa *BinaryServerAdapter) SendEcho(payload *Payload) error { 29 | return websocket.Message.Send(bsa.conn, payloadToBinaryMsg(MsgClientEcho, payload)) 30 | } 31 | 32 | func (bsa *BinaryServerAdapter) SendBroadcast(payload *Payload) error { 33 | return websocket.Message.Send(bsa.conn, payloadToBinaryMsg(MsgClientBroadcast, payload)) 34 | } 35 | 36 | func (bsa *BinaryServerAdapter) Receive() (*serverSentMsg, error) { 37 | var buf []byte 38 | err := websocket.Message.Receive(bsa.conn, &buf) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | var msg serverSentMsg 44 | msg.Type = buf[0] 45 | // ignoring payload size as it can be inferred from frame size -- may need to revisit this if messages span frames 46 | payload := &Payload{ 47 | SendTime: time.Unix(0, int64(binary.BigEndian.Uint64(buf[5:13]))), 48 | } 49 | 50 | if len(buf) > 13 { 51 | payload.Padding = buf[13:] 52 | } 53 | 54 | msg.Payload = payload 55 | 56 | return &msg, nil 57 | } 58 | -------------------------------------------------------------------------------- /benchmark/phoenix_server_adapter.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "fmt" 5 | "golang.org/x/net/websocket" 6 | "strconv" 7 | "time" 8 | ) 9 | 10 | type PhoenixServerAdapter struct { 11 | conn *websocket.Conn 12 | } 13 | 14 | type psaMsg struct { 15 | Topic string `json:"topic"` 16 | Event string `json:"event"` 17 | Payload map[string]interface{} `json:"payload"` 18 | Ref string `json:"ref"` 19 | } 20 | 21 | func (psa *PhoenixServerAdapter) Startup() error { 22 | err := websocket.JSON.Send(psa.conn, &psaMsg{ 23 | Topic: "room:lobby", 24 | Event: "phx_join", 25 | }) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | var confirmSubMsg psaMsg 31 | err = websocket.JSON.Receive(psa.conn, &confirmSubMsg) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | if confirmSubMsg.Topic != "room:lobby" || confirmSubMsg.Event != `phx_reply` { 37 | return fmt.Errorf("expected phx_reply msg, got %v", confirmSubMsg) 38 | } 39 | 40 | return nil 41 | } 42 | 43 | func (psa *PhoenixServerAdapter) SendEcho(payload *Payload) error { 44 | return websocket.JSON.Send(psa.conn, &psaMsg{ 45 | Topic: "room:lobby", 46 | Event: "echo", 47 | Payload: map[string]interface{}{"body": payload}, 48 | }) 49 | } 50 | 51 | func (psa *PhoenixServerAdapter) SendBroadcast(payload *Payload) error { 52 | return websocket.JSON.Send(psa.conn, &psaMsg{ 53 | Topic: "room:lobby", 54 | Event: "broadcast", 55 | Payload: map[string]interface{}{"body": payload}, 56 | }) 57 | } 58 | 59 | func (psa *PhoenixServerAdapter) Receive() (*serverSentMsg, error) { 60 | var msg psaMsg 61 | err := websocket.JSON.Receive(psa.conn, &msg) 62 | if err != nil { 63 | return nil, err 64 | } 65 | if msg.Topic != "room:lobby" { 66 | return nil, fmt.Errorf("unexpected msg, got %v", msg) 67 | } 68 | 69 | var psaPayload map[string]interface{} 70 | 71 | if response, ok := msg.Payload["response"].(map[string]interface{}); ok { 72 | psaPayload = response 73 | } else { 74 | psaPayload = msg.Payload 75 | } 76 | 77 | body := psaPayload["body"].(map[string]interface{}) 78 | 79 | payload := &Payload{} 80 | unixNanosecond, err := strconv.ParseInt(body["sendTime"].(string), 10, 64) 81 | if err != nil { 82 | return nil, err 83 | } 84 | payload.SendTime = time.Unix(0, unixNanosecond) 85 | 86 | if padding, ok := body["padding"]; ok { 87 | payload.Padding = []byte(padding.(string)) 88 | } 89 | 90 | msgType, err := ParseMessageType(psaPayload["type"].(string)) 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | return &serverSentMsg{Type: msgType, Payload: payload}, nil 96 | } 97 | -------------------------------------------------------------------------------- /benchmark/result_recorder.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "time" 8 | ) 9 | 10 | type ResultRecorder interface { 11 | Record( 12 | clientCount int, 13 | limitPercentile int, 14 | rttPercentile time.Duration, 15 | rttMin time.Duration, 16 | rttMedian time.Duration, 17 | rttMax time.Duration, 18 | ) error 19 | Message(str string) 20 | Flush() error 21 | } 22 | 23 | type JSONResultRecorder struct { 24 | w io.Writer 25 | messages []string 26 | records []map[string]interface{} 27 | } 28 | 29 | func NewJSONResultRecorder(w io.Writer) *JSONResultRecorder { 30 | return &JSONResultRecorder{w: w} 31 | } 32 | 33 | func (jrr *JSONResultRecorder) Record( 34 | clientCount, limitPercentile int, 35 | rttPercentile, rttMin, rttMedian, rttMax time.Duration, 36 | ) error { 37 | record := map[string]interface{}{ 38 | "time": time.Now().Format(time.RFC3339), 39 | "clients": clientCount, 40 | "limit_per": limitPercentile, 41 | "per-rtt": roundToMS(rttPercentile), 42 | "min-rtt": roundToMS(rttMin), 43 | "median-rtt": roundToMS(rttMedian), 44 | "max-rtt": roundToMS(rttMax), 45 | } 46 | 47 | jrr.records = append(jrr.records, record) 48 | 49 | return nil 50 | } 51 | 52 | func (jrr *JSONResultRecorder) Message(str string) { 53 | jrr.messages = append(jrr.messages, str) 54 | } 55 | 56 | func (jrr *JSONResultRecorder) Flush() error { 57 | res := map[string]interface{}{"steps": jrr.records, "messages": jrr.messages} 58 | jsonString, err := json.Marshal(res) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | if _, err := jrr.w.Write(jsonString); err != nil { 64 | return err 65 | } 66 | 67 | return nil 68 | } 69 | 70 | type TextResultRecorder struct { 71 | w io.Writer 72 | } 73 | 74 | func NewTextResultRecorder(w io.Writer) *TextResultRecorder { 75 | return &TextResultRecorder{w: w} 76 | } 77 | 78 | func (trr *TextResultRecorder) Flush() error { 79 | // Do nothing 80 | return nil 81 | } 82 | 83 | func (trr *TextResultRecorder) Message(str string) { 84 | fmt.Println(str) 85 | } 86 | 87 | func (trr *TextResultRecorder) Record( 88 | clientCount, limitPercentile int, 89 | rttPercentile, rttMin, rttMedian, rttMax time.Duration, 90 | ) error { 91 | _, err := fmt.Fprintf(trr.w, 92 | "[%s] clients: %5d %dper-rtt: %3dms min-rtt: %3dms median-rtt: %3dms max-rtt: %3dms\n", 93 | time.Now().Format(time.RFC3339), 94 | clientCount, 95 | limitPercentile, 96 | roundToMS(rttPercentile), 97 | roundToMS(rttMin), 98 | roundToMS(rttMedian), 99 | roundToMS(rttMax), 100 | ) 101 | 102 | return err 103 | } 104 | 105 | func roundToMS(d time.Duration) int64 { 106 | return int64((d + (500 * time.Microsecond)) / time.Millisecond) 107 | } 108 | -------------------------------------------------------------------------------- /benchmark/connect_benchmark.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "math/rand" 7 | "sync" 8 | "sync/atomic" 9 | "time" 10 | 11 | "github.com/cheggaaa/pb/v3" 12 | ) 13 | 14 | type ConnectBenchmark struct { 15 | errChan chan error 16 | resChan chan time.Duration 17 | 18 | Config 19 | 20 | clientsCount uint64 21 | } 22 | 23 | func NewConnect(config *Config) *ConnectBenchmark { 24 | b := &ConnectBenchmark{Config: *config} 25 | 26 | b.errChan = make(chan error) 27 | b.resChan = make(chan time.Duration) 28 | 29 | return b 30 | } 31 | 32 | func (b *ConnectBenchmark) Run() error { 33 | stepNum := 0 34 | drop := 0 35 | 36 | for { 37 | 38 | stepDrop := 0 39 | 40 | stepNum++ 41 | 42 | bar := pb.StartNew(b.StepSize) 43 | 44 | go b.startClients(b.Concurrent, b.StepSize) 45 | 46 | var resAgg rttAggregate 47 | for resAgg.Count()+stepDrop < b.StepSize { 48 | select { 49 | case result := <-b.resChan: 50 | bar.Increment() 51 | resAgg.Add(result) 52 | case err := <-b.errChan: 53 | debug(fmt.Sprintf("error: %v", err)) 54 | stepDrop++ 55 | bar.Increment() 56 | } 57 | } 58 | 59 | bar.Finish() 60 | 61 | drop += stepDrop 62 | 63 | err := b.ResultRecorder.Record( 64 | int(b.clientsCount)-drop, 65 | b.LimitPercentile, 66 | resAgg.Percentile(b.LimitPercentile), 67 | resAgg.Min(), 68 | resAgg.Percentile(50), 69 | resAgg.Max(), 70 | ) 71 | 72 | if err != nil { 73 | return err 74 | } 75 | 76 | if b.Interactive { 77 | promptToContinue() 78 | } 79 | 80 | if b.StepDelay > 0 { 81 | time.Sleep(b.StepDelay) 82 | } 83 | 84 | if b.TotalSteps > 0 && b.TotalSteps == stepNum { 85 | return nil 86 | } 87 | } 88 | } 89 | 90 | func (b *ConnectBenchmark) startClient(serverType string) error { 91 | cp := b.ClientPools[int(b.clientsCount)%len(b.ClientPools)] 92 | 93 | if b.CommandDelay > 0 && b.CommandDelayChance > rand.Intn(100) { 94 | time.Sleep(b.CommandDelay) 95 | } 96 | 97 | atomic.AddUint64(&b.clientsCount, 1) 98 | _, err := cp.New(int(b.clientsCount), b.WebsocketURL, b.WebsocketOrigin, b.ServerType, b.resChan, b.errChan, nil) 99 | 100 | if err != nil { 101 | return err 102 | } 103 | 104 | return nil 105 | } 106 | 107 | func (b *ConnectBenchmark) startClients(num int, total int) { 108 | created := 0 109 | 110 | for created < total { 111 | var waitgroup sync.WaitGroup 112 | 113 | toCreate := int(math.Min(float64(num), float64(total-created))) 114 | 115 | for i := 0; i < toCreate; i++ { 116 | waitgroup.Add(1) 117 | 118 | go func() { 119 | if err := b.startClient(b.ServerType); err != nil { 120 | b.errChan <- err 121 | } 122 | waitgroup.Done() 123 | }() 124 | } 125 | waitgroup.Wait() 126 | created += toCreate 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /benchmark/pusher_server_connect_adapter.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "sync" 8 | "time" 9 | 10 | "golang.org/x/net/websocket" 11 | ) 12 | 13 | var PusherConnectConfig struct { 14 | Channel string 15 | } 16 | 17 | type PusherServerConnectAdapter struct { 18 | conn *websocket.Conn 19 | connected bool 20 | mu sync.Mutex 21 | socketId string 22 | initTime time.Time 23 | socketID string 24 | 25 | // skip Unsubscribe for AnyCable 26 | skipUnsubscribe bool 27 | } 28 | 29 | func (psca *PusherServerConnectAdapter) Startup() error { 30 | if !channelNameRegexp.MatchString(PusherConnectConfig.Channel) { 31 | return fmt.Errorf("invalid channel name %q", PusherConnectConfig.Channel) 32 | } 33 | psca.connected = false 34 | return nil 35 | } 36 | 37 | func (psca *PusherServerConnectAdapter) Connected(ts time.Time) error { 38 | psca.initTime = ts 39 | psca.connected = false 40 | return nil 41 | } 42 | 43 | func (psca *PusherServerConnectAdapter) Receive() (*serverSentMsg, error) { 44 | ctx, cancel := context.WithTimeout(context.Background(), ConnectionTimeout) 45 | defer cancel() 46 | if err := psca.EnsureConnected(ctx); err != nil { 47 | return nil, err 48 | } 49 | return &serverSentMsg{Type: MsgServerEcho, Payload: &Payload{SendTime: psca.initTime}}, nil 50 | } 51 | 52 | func (psca *PusherServerConnectAdapter) SendEcho(payload *Payload) error { return nil } 53 | func (psca *PusherServerConnectAdapter) SendBroadcast(payload *Payload) error { return nil } 54 | 55 | func (psca *PusherServerConnectAdapter) EnsureConnected(ctx context.Context) error { 56 | psca.mu.Lock() 57 | defer psca.mu.Unlock() 58 | 59 | if psca.connected { 60 | return nil 61 | } 62 | 63 | res := make(chan error, 1) 64 | go func() { 65 | sockID, err := waitConnectionEstablished(psca.conn) 66 | if err != nil { 67 | res <- err 68 | return 69 | } 70 | psca.socketID = sockID 71 | 72 | sub := map[string]interface{}{"event": SubscribeType, "data": map[string]string{"channel": PusherConnectConfig.Channel}} 73 | if err := websocket.JSON.Send(psca.conn, sub); err != nil { 74 | res <- err 75 | return 76 | } 77 | 78 | for { 79 | msg, err := receiveIgnoringPing(psca.conn) 80 | if err != nil { 81 | res <- err 82 | return 83 | } 84 | if (msg.Event == InternalSubscriptionSucceededType) && msg.Channel == PusherConnectConfig.Channel { 85 | psca.connected = true 86 | if !psca.skipUnsubscribe { 87 | _ = websocket.JSON.Send(psca.conn, map[string]interface{}{"event": UnsubscribeType, "data": map[string]string{"channel": PusherConnectConfig.Channel}}) 88 | } 89 | res <- nil 90 | return 91 | } 92 | } 93 | }() 94 | 95 | select { 96 | case <-ctx.Done(): 97 | return errors.New("connection timeout exceeded") 98 | case err := <-res: 99 | return err 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /benchmark/action_cable_server_connect_adapter.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | "time" 8 | 9 | "golang.org/x/net/websocket" 10 | ) 11 | 12 | type ActionCableServerConnectAdapter struct { 13 | conn *websocket.Conn 14 | initTime time.Time 15 | connected bool 16 | mu sync.Mutex 17 | codec websocket.Codec 18 | } 19 | 20 | func (acsa *ActionCableServerConnectAdapter) Startup() error { 21 | if CableConfig.Encoding == "msgpack" { 22 | acsa.codec = MsgPackCodec 23 | } else if CableConfig.Encoding == "protobuf" { 24 | acsa.codec = ProtoBufCodec 25 | } else { 26 | acsa.codec = websocket.JSON 27 | } 28 | 29 | return nil 30 | } 31 | 32 | func (acsa *ActionCableServerConnectAdapter) Connected(ts time.Time) error { 33 | acsa.initTime = ts 34 | acsa.connected = false 35 | return nil 36 | } 37 | 38 | func (acsa *ActionCableServerConnectAdapter) Receive() (*serverSentMsg, error) { 39 | ctx, cancel := context.WithTimeout(context.Background(), ConnectionTimeout) 40 | defer cancel() 41 | 42 | err := acsa.EnsureConnected(ctx) 43 | 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | payload := &Payload{} 49 | 50 | payload.SendTime = acsa.initTime 51 | 52 | // use echo type to collect results 53 | return &serverSentMsg{Type: MsgServerEcho, Payload: payload}, nil 54 | } 55 | 56 | func (acsa *ActionCableServerConnectAdapter) SendEcho(payload *Payload) error { 57 | return nil 58 | } 59 | 60 | func (acsa *ActionCableServerConnectAdapter) SendBroadcast(payload *Payload) error { 61 | return nil 62 | } 63 | 64 | func (acsa *ActionCableServerConnectAdapter) receiveIgnoringPing() (*acsaMsg, error) { 65 | for { 66 | var msg acsaMsg 67 | err := acsa.codec.Receive(acsa.conn, &msg) 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | if msg.Type == "ping" { 73 | continue 74 | } 75 | 76 | return &msg, nil 77 | } 78 | } 79 | 80 | func (acsa *ActionCableServerConnectAdapter) EnsureConnected(ctx context.Context) error { 81 | acsa.mu.Lock() 82 | defer acsa.mu.Unlock() 83 | 84 | if acsa.connected { 85 | return nil 86 | } 87 | 88 | resChan := make(chan error) 89 | 90 | go func() { 91 | welcomeMsg, err := acsa.receiveIgnoringPing() 92 | if err != nil { 93 | resChan <- err 94 | return 95 | } 96 | if welcomeMsg.Type != "welcome" { 97 | resChan <- fmt.Errorf("expected welcome msg, got %v", welcomeMsg) 98 | return 99 | } 100 | 101 | err = acsa.codec.Send(acsa.conn, &acsaMsg{ 102 | Command: "subscribe", 103 | Identifier: CableConfig.Channel, 104 | }) 105 | if err != nil { 106 | resChan <- err 107 | return 108 | } 109 | 110 | confirmMsg, err := acsa.receiveIgnoringPing() 111 | if err != nil { 112 | resChan <- err 113 | return 114 | } 115 | 116 | if confirmMsg.Type != "confirm_subscription" { 117 | resChan <- fmt.Errorf("expected confirm msg, got %v", confirmMsg) 118 | return 119 | } 120 | 121 | resChan <- nil 122 | }() 123 | 124 | select { 125 | case <-ctx.Done(): 126 | return fmt.Errorf("Connection timeout exceeded: started at %s, now %s", acsa.initTime.Format(time.RFC3339), time.Now().Format(time.RFC3339)) 127 | case err := <-resChan: 128 | if err != nil { 129 | acsa.connected = true 130 | } 131 | return err 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /benchmark/remote_client.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "log" 7 | "net" 8 | "time" 9 | ) 10 | 11 | type RemoteClientPool struct { 12 | conn net.Conn 13 | encoder *json.Encoder 14 | 15 | clients map[int]*remoteClient 16 | connectSuccessChan chan struct{} 17 | } 18 | 19 | type remoteClient struct { 20 | clientPool *RemoteClientPool 21 | id int 22 | 23 | rttResultChan chan time.Duration 24 | errChan chan error 25 | rxBroadcastCountChan chan int 26 | } 27 | 28 | func NewRemoteClientPool(addr string) (*RemoteClientPool, error) { 29 | rcp := &RemoteClientPool{} 30 | rcp.clients = make(map[int]*remoteClient) 31 | rcp.connectSuccessChan = make(chan struct{}) 32 | 33 | var err error 34 | rcp.conn, err = net.Dial("tcp", addr) 35 | if err != nil { 36 | return nil, err 37 | } 38 | rcp.encoder = json.NewEncoder(rcp.conn) 39 | 40 | go rcp.rx() 41 | 42 | return rcp, nil 43 | } 44 | 45 | func (rcp *RemoteClientPool) New( 46 | id int, 47 | dest, origin, serverType string, 48 | rttResultChan chan time.Duration, 49 | errChan chan error, 50 | padding []byte, 51 | ) (Client, error) { 52 | client := &remoteClient{ 53 | clientPool: rcp, 54 | id: id, 55 | rttResultChan: rttResultChan, 56 | errChan: errChan, 57 | rxBroadcastCountChan: make(chan int), 58 | } 59 | rcp.clients[id] = client 60 | 61 | msg := WorkerMsg{ 62 | ClientID: id, 63 | Type: "connect", 64 | } 65 | msg.Connect = &WorkerConnectMsg{ 66 | Dest: dest, 67 | Origin: origin, 68 | ServerType: serverType, 69 | Padding: padding, 70 | } 71 | 72 | err := rcp.encoder.Encode(msg) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | <-rcp.connectSuccessChan 78 | 79 | return client, nil 80 | } 81 | 82 | func (rcp *RemoteClientPool) rx() { 83 | decoder := json.NewDecoder(rcp.conn) 84 | 85 | for { 86 | var msg WorkerMsg 87 | err := decoder.Decode(&msg) 88 | if err != nil { 89 | log.Println(err) 90 | return 91 | } 92 | 93 | switch msg.Type { 94 | case "connect": 95 | rcp.connectSuccessChan <- struct{}{} 96 | case "rttResult": 97 | rcp.clients[msg.ClientID].rttResultChan <- msg.RTTResult.Duration 98 | case "error": 99 | rcp.clients[msg.ClientID].errChan <- errors.New(msg.Error.Msg) 100 | case "rxBroadcastCount": 101 | rcp.clients[msg.ClientID].rxBroadcastCountChan <- msg.RxBroadcastCount.Count 102 | default: 103 | log.Println("unknown message:", msg.Type) 104 | } 105 | 106 | } 107 | } 108 | 109 | func (rcp *RemoteClientPool) Close() error { 110 | return rcp.conn.Close() 111 | } 112 | 113 | func (c *remoteClient) SendEcho() error { 114 | msg := WorkerMsg{ 115 | ClientID: c.id, 116 | Type: "echo", 117 | } 118 | 119 | return c.clientPool.encoder.Encode(msg) 120 | } 121 | 122 | func (c *remoteClient) SendBroadcast() error { 123 | msg := WorkerMsg{ 124 | ClientID: c.id, 125 | Type: "broadcast", 126 | } 127 | 128 | return c.clientPool.encoder.Encode(msg) 129 | } 130 | 131 | func (c *remoteClient) ResetRxBroadcastCount() (int, error) { 132 | msg := WorkerMsg{ 133 | ClientID: c.id, 134 | Type: "resetRxBroadcastCount", 135 | } 136 | 137 | err := c.clientPool.encoder.Encode(msg) 138 | if err != nil { 139 | return 0, err 140 | } 141 | 142 | count := <-c.rxBroadcastCountChan 143 | 144 | return count, nil 145 | } 146 | -------------------------------------------------------------------------------- /benchmark/pusher_common.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "errors" 8 | "fmt" 9 | "golang.org/x/net/websocket" 10 | "regexp" 11 | "strconv" 12 | "time" 13 | ) 14 | 15 | import ( 16 | "encoding/json" 17 | ) 18 | 19 | const ( 20 | ClientBroadcastEvent = "client-broadcast" 21 | PingType = "pusher:ping" 22 | PongType = "pusher:pong" 23 | SubscribeType = "pusher:subscribe" 24 | ConnectionEstablishedType = "pusher:connection_established" 25 | SubscriptionSucceededType = "pusher_internal:subscription_succeeded" 26 | InternalSubscriptionSucceededType = "pusher_internal:subscription_succeeded" 27 | UnsubscribeType = "pusher:unsubscribe" 28 | ) 29 | 30 | var channelNameRegexp = regexp.MustCompile(`^[A-Za-z0-9_=\-@,.;]+$`) 31 | 32 | type PusherCommonConfig struct { 33 | Channel string 34 | AppID string 35 | AppKey string 36 | AppSecret string 37 | Host string 38 | Port string 39 | } 40 | 41 | type pusherMsg struct { 42 | Event string `json:"event"` 43 | Channel string `json:"channel,omitempty"` 44 | Data json.RawMessage `json:"data,omitempty"` 45 | } 46 | 47 | type broadcastPayload struct { 48 | SenderSocketID string `json:"sender_socket_id"` 49 | Action string `json:"action"` 50 | Payload interface{} `json:"payload"` 51 | } 52 | 53 | func receiveIgnoringPing(conn *websocket.Conn) (*pusherMsg, error) { 54 | for { 55 | var msg pusherMsg 56 | if err := websocket.JSON.Receive(conn, &msg); err != nil { 57 | return nil, err 58 | } 59 | if msg.Event == PingType { 60 | _ = websocket.JSON.Send(conn, map[string]interface{}{"event": PongType}) 61 | continue 62 | } 63 | return &msg, nil 64 | } 65 | } 66 | 67 | func waitConnectionEstablished(conn *websocket.Conn) (string, error) { 68 | msg, err := receiveIgnoringPing(conn) 69 | if err != nil { 70 | return "", err 71 | } 72 | if msg.Event != ConnectionEstablishedType { 73 | return "", fmt.Errorf("expected %s, got %s", ConnectionEstablishedType, msg.Event) 74 | } 75 | 76 | // data can come either as a string or an object 77 | type socketPayload struct { 78 | SocketID string `json:"socket_id"` 79 | } 80 | var inner string 81 | var sp socketPayload 82 | 83 | if err := json.Unmarshal(msg.Data, &inner); err == nil { 84 | if err := json.Unmarshal([]byte(inner), &sp); err == nil && sp.SocketID != "" { 85 | return sp.SocketID, nil 86 | } 87 | } 88 | 89 | if err := json.Unmarshal(msg.Data, &sp); err != nil { 90 | return "", err 91 | } 92 | if sp.SocketID == "" { 93 | return "", errors.New("socket_id is empty") 94 | } 95 | return sp.SocketID, nil 96 | } 97 | 98 | func waitSubscriptionSucceeded(conn *websocket.Conn, channel string) error { 99 | for { 100 | msg, err := receiveIgnoringPing(conn) 101 | if err != nil { 102 | return err 103 | } 104 | if (msg.Event == SubscriptionSucceededType || 105 | msg.Event == InternalSubscriptionSucceededType) && 106 | msg.Channel == channel { 107 | return nil 108 | } 109 | } 110 | } 111 | 112 | func parseBroadcast(raw *pusherMsg, selfSocketID string) (*serverSentMsg, error) { 113 | if raw.Event != ClientBroadcastEvent { 114 | return nil, nil 115 | } 116 | 117 | var dataStr string 118 | if err := json.Unmarshal(raw.Data, &dataStr); err != nil { 119 | return nil, fmt.Errorf("unwrap data: %w", err) 120 | } 121 | 122 | var message broadcastPayload 123 | if err := json.Unmarshal([]byte(dataStr), &message); err != nil { 124 | return nil, fmt.Errorf("unmarshal payload: %w", err) 125 | } 126 | 127 | if message.Action != "broadcast" { 128 | return nil, nil 129 | } 130 | 131 | pm, ok := message.Payload.(map[string]interface{}) 132 | if !ok { 133 | return nil, fmt.Errorf("unexpected payload type: %T", message.Payload) 134 | } 135 | 136 | nsec, err := strconv.ParseInt(pm["sendTime"].(string), 10, 64) 137 | if err != nil { 138 | return nil, err 139 | } 140 | pl := &Payload{SendTime: time.Unix(0, nsec)} 141 | if pad, ok := pm["padding"]; ok { 142 | if b, err := json.Marshal(pad); err == nil { 143 | pl.Padding = b 144 | } 145 | } 146 | 147 | if message.SenderSocketID == selfSocketID { 148 | return &serverSentMsg{Type: MsgServerBroadcastResult, Payload: pl}, nil 149 | } 150 | return &serverSentMsg{Type: MsgServerBroadcast, Payload: pl}, nil 151 | } 152 | 153 | func signChannel(socketID, channel, secret string) string { 154 | mac := hmac.New(sha256.New, []byte(secret)) 155 | mac.Write([]byte(socketID + ":" + channel)) 156 | return hex.EncodeToString(mac.Sum(nil)) 157 | } 158 | -------------------------------------------------------------------------------- /benchmark/pusher_server_adapter.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | pusher "github.com/pusher/pusher-http-go/v5" 8 | "golang.org/x/net/websocket" 9 | "sync" 10 | ) 11 | 12 | var PusherConfig struct { 13 | PusherCommonConfig 14 | Host string 15 | Port string 16 | } 17 | 18 | type PusherServerAdapter struct { 19 | conn *websocket.Conn 20 | socketID string 21 | connected bool 22 | 23 | httpClient *pusher.Client 24 | 25 | mu sync.Mutex 26 | muPending sync.Mutex 27 | pending []*serverSentMsg // We need it to store the originator Result to send it later 28 | } 29 | 30 | func (psa *PusherServerAdapter) Startup() error { 31 | if !channelNameRegexp.MatchString(PusherConfig.Channel) { 32 | return fmt.Errorf("invalid channel name %q", PusherConfig.Channel) 33 | } 34 | 35 | // We use the HTTP API instead of "client‑events" for two reasons: 36 | // 1. A Pusher client event is not delivered back to its sender, therefore the originator will never see its own message and the 37 | // benchmark will not be able to calculate RTT 38 | // https://pusher.com/docs/channels/using_channels/events/#triggering-client-events 39 | // Client events are not delivered to the originator of the event 40 | // 41 | // 2. Because of the first reason, we cannot know 42 | // when to emulate a MsgServerBroadcastResult. 43 | // 44 | // The HTTP trigger has no this limitation: every connection (including the originator) receives the event 45 | psa.httpClient = &pusher.Client{ 46 | AppID: PusherConfig.AppID, 47 | Key: PusherConfig.AppKey, 48 | Secret: PusherConfig.AppSecret, 49 | Cluster: "", 50 | Secure: false, 51 | Host: fmt.Sprintf("%s:%s", PusherConfig.Host, PusherConfig.Port), 52 | } 53 | 54 | return nil 55 | } 56 | 57 | func (a *PusherServerAdapter) SendEcho(_ *Payload) error { return nil } 58 | 59 | func (psa *PusherServerAdapter) SendBroadcast(payload *Payload) error { 60 | if !psa.connected { 61 | ctx, cancel := context.WithTimeout(context.Background(), ConnectionTimeout) 62 | defer cancel() 63 | 64 | if err := psa.EnsureConnected(ctx); err != nil { 65 | return err 66 | } 67 | } 68 | 69 | body := map[string]interface{}{ 70 | "action": "broadcast", 71 | "sender_socket_id": psa.socketID, 72 | "payload": payloadTojsonPayload(payload), 73 | } 74 | 75 | return psa.httpClient.Trigger(PusherConfig.Channel, ClientBroadcastEvent, body) 76 | } 77 | 78 | func (psa *PusherServerAdapter) Receive() (*serverSentMsg, error) { 79 | if !psa.connected { 80 | if err := psa.EnsureConnected(context.Background()); err != nil { 81 | return nil, err 82 | } 83 | } 84 | 85 | psa.muPending.Lock() 86 | if n := len(psa.pending); n > 0 { 87 | m := psa.pending[0] 88 | psa.pending = psa.pending[1:] 89 | psa.muPending.Unlock() 90 | return m, nil 91 | } 92 | psa.muPending.Unlock() 93 | 94 | for { 95 | msg, err := receiveIgnoringPing(psa.conn) 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | message, err := parseBroadcast(msg, psa.socketID) 101 | if err != nil { 102 | return nil, err 103 | } 104 | if message == nil { 105 | continue 106 | } 107 | 108 | if message.Type == MsgServerBroadcastResult { 109 | psa.muPending.Lock() 110 | psa.pending = append(psa.pending, message) 111 | psa.muPending.Unlock() 112 | 113 | return &serverSentMsg{Type: MsgServerBroadcast, Payload: message.Payload}, nil 114 | } 115 | 116 | return message, nil 117 | } 118 | } 119 | 120 | func (psa *PusherServerAdapter) EnsureConnected(ctx context.Context) error { 121 | psa.mu.Lock() 122 | defer psa.mu.Unlock() 123 | 124 | if psa.connected { 125 | return nil 126 | } 127 | 128 | res := make(chan error, 1) 129 | go func() { 130 | sockID, err := waitConnectionEstablished(psa.conn) 131 | if err != nil { 132 | res <- err 133 | return 134 | } 135 | psa.socketID = sockID 136 | 137 | sig := signChannel(psa.socketID, PusherConfig.Channel, PusherConfig.AppSecret) 138 | auth := PusherConfig.AppKey + ":" + sig 139 | 140 | subscribe := map[string]interface{}{ 141 | "event": SubscribeType, 142 | "data": map[string]string{ 143 | "channel": PusherConfig.Channel, 144 | "auth": auth, 145 | }, 146 | } 147 | 148 | if err := websocket.JSON.Send(psa.conn, subscribe); err != nil { 149 | res <- err 150 | return 151 | } 152 | 153 | if err := waitSubscriptionSucceeded(psa.conn, PusherConfig.Channel); err != nil { 154 | res <- err 155 | return 156 | } 157 | psa.connected = true 158 | res <- nil 159 | }() 160 | 161 | select { 162 | case <-ctx.Done(): 163 | return errors.New("connection timeout exceeded") 164 | case err := <-res: 165 | return err 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /benchmark/worker.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net" 7 | "strconv" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | type WorkerMsg struct { 13 | ClientID int `json:"clientID"` 14 | Type string `json:"type"` 15 | Connect *WorkerConnectMsg `json:"connect,omitempty"` 16 | RTTResult *WorkerRTTResultMsg `json:"rttResult,omitempty"` 17 | Error *WorkerErrorMsg `json:"error,omitempty"` 18 | RxBroadcastCount *WorkerRxBroadcastCountMsg `json:"rxBroadcastCount,omitempty"` 19 | } 20 | 21 | type WorkerConnectMsg struct { 22 | Dest string 23 | Origin string 24 | ServerType string 25 | Padding []byte 26 | } 27 | 28 | type WorkerRTTResultMsg struct { 29 | Duration time.Duration 30 | } 31 | 32 | type WorkerErrorMsg struct { 33 | Msg string 34 | } 35 | 36 | type WorkerRxBroadcastCountMsg struct { 37 | Count int 38 | } 39 | 40 | type Worker struct { 41 | listener net.Listener 42 | laddr string 43 | } 44 | 45 | type workerConn struct { 46 | conn net.Conn 47 | encoder *json.Encoder 48 | clientPools []ClientPool 49 | clients map[int]Client 50 | 51 | closedMutex sync.Mutex 52 | closed bool 53 | } 54 | 55 | func NewWorker(addr string, port uint16) *Worker { 56 | w := &Worker{} 57 | w.laddr = net.JoinHostPort(addr, strconv.FormatInt(int64(port), 10)) 58 | 59 | return w 60 | } 61 | 62 | func (w *Worker) Serve() error { 63 | listener, err := net.Listen("tcp", w.laddr) 64 | if err != nil { 65 | return err 66 | } 67 | defer listener.Close() 68 | 69 | for { 70 | conn, err := listener.Accept() 71 | if err != nil { 72 | return err 73 | } 74 | 75 | wc := &workerConn{ 76 | conn: conn, 77 | encoder: json.NewEncoder(conn), 78 | clientPools: []ClientPool{NewLocalClientPool(nil)}, 79 | clients: make(map[int]Client), 80 | } 81 | 82 | go wc.work() 83 | } 84 | } 85 | 86 | func (wc *workerConn) rx(clientID int, rttResultChan chan time.Duration, errChan chan error) { 87 | for { 88 | select { 89 | case result := <-rttResultChan: 90 | msg := WorkerMsg{ 91 | ClientID: clientID, 92 | Type: "rttResult", 93 | RTTResult: &WorkerRTTResultMsg{Duration: result}, 94 | } 95 | 96 | if err := wc.encoder.Encode(msg); err != nil { 97 | log.Fatalln(err) 98 | } 99 | case err := <-errChan: 100 | wc.closedMutex.Lock() 101 | closed := wc.closed 102 | wc.closedMutex.Unlock() 103 | if closed { 104 | return 105 | } 106 | 107 | msg := WorkerMsg{ 108 | ClientID: clientID, 109 | Type: "error", 110 | Error: &WorkerErrorMsg{Msg: err.Error()}, 111 | } 112 | 113 | if err := wc.encoder.Encode(msg); err != nil { 114 | log.Fatalln(err) 115 | } 116 | 117 | return 118 | } 119 | } 120 | } 121 | func (wc *workerConn) close() { 122 | wc.conn.Close() 123 | 124 | wc.closedMutex.Lock() 125 | wc.closed = true 126 | wc.closedMutex.Unlock() 127 | 128 | for _, cp := range wc.clientPools { 129 | err := cp.Close() 130 | if err != nil { 131 | log.Fatalln(err) 132 | } 133 | } 134 | } 135 | 136 | func (wc *workerConn) work() { 137 | defer wc.close() 138 | 139 | log.Println(wc.conn.RemoteAddr().String(), "Accepted") 140 | 141 | decoder := json.NewDecoder(wc.conn) 142 | 143 | for { 144 | var msg WorkerMsg 145 | err := decoder.Decode(&msg) 146 | if err != nil { 147 | log.Println(wc.conn.RemoteAddr().String(), err) 148 | return 149 | } 150 | 151 | switch msg.Type { 152 | case "connect": 153 | cp := wc.clientPools[len(wc.clients)%len(wc.clientPools)] 154 | rttResultChan := make(chan time.Duration) 155 | errChan := make(chan error) 156 | 157 | c, err := cp.New(msg.ClientID, msg.Connect.Dest, msg.Connect.Origin, msg.Connect.ServerType, rttResultChan, errChan, msg.Connect.Padding) 158 | if err != nil { 159 | log.Println(err) 160 | return 161 | } 162 | wc.clients[msg.ClientID] = c 163 | 164 | // Send exact message back as confirmation of connection 165 | if err := wc.encoder.Encode(msg); err != nil { 166 | log.Fatalln(err) 167 | } 168 | 169 | go wc.rx(msg.ClientID, rttResultChan, errChan) 170 | case "echo": 171 | wc.clients[msg.ClientID].SendEcho() 172 | case "broadcast": 173 | wc.clients[msg.ClientID].SendBroadcast() 174 | case "resetRxBroadcastCount": 175 | count, err := wc.clients[msg.ClientID].ResetRxBroadcastCount() 176 | if err != nil { 177 | log.Println(err) 178 | return 179 | } 180 | encoder := json.NewEncoder(wc.conn) 181 | msg := WorkerMsg{ 182 | ClientID: msg.ClientID, 183 | Type: "rxBroadcastCount", 184 | RxBroadcastCount: &WorkerRxBroadcastCountMsg{Count: count}, 185 | } 186 | 187 | if err := encoder.Encode(msg); err != nil { 188 | log.Fatalln(err) 189 | } 190 | 191 | default: 192 | log.Println("unknown message:", msg.Type) 193 | } 194 | 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /benchmark/anycable_pusher_adapter.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "github.com/go-redis/redis/v8" 10 | "golang.org/x/net/websocket" 11 | "net/http" 12 | "sync" 13 | ) 14 | 15 | var AnyCablePusherConfig struct { 16 | PusherCommonConfig 17 | RedisAddr string 18 | HTTPAddr string 19 | Backend string 20 | } 21 | 22 | type AnyCablePusherAdapter struct { 23 | conn *websocket.Conn 24 | socketID string 25 | connected bool 26 | 27 | redis *redis.Client 28 | mu sync.Mutex 29 | 30 | muPending sync.Mutex 31 | pending []*serverSentMsg // We need it to store the originator Result to send it later 32 | } 33 | 34 | func (apa *AnyCablePusherAdapter) Startup() error { 35 | if !channelNameRegexp.MatchString(AnyCablePusherConfig.Channel) { 36 | return fmt.Errorf("invalid channel name %q", AnyCablePusherConfig.Channel) 37 | } 38 | if AnyCablePusherConfig.Backend == "redis" { 39 | apa.redis = redis.NewClient(&redis.Options{Addr: AnyCablePusherConfig.RedisAddr}) 40 | } 41 | 42 | if err := apa.EnsureConnected(context.Background()); err != nil { 43 | return err 44 | } 45 | 46 | return nil 47 | } 48 | 49 | func (apa *AnyCablePusherAdapter) SendEcho(_ *Payload) error { return nil } 50 | 51 | func (apa *AnyCablePusherAdapter) SendBroadcast(payload *Payload) error { 52 | msgBody, _ := json.Marshal(map[string]interface{}{ 53 | "action": "broadcast", 54 | "sender_socket_id": apa.socketID, 55 | "payload": payloadTojsonPayload(payload), 56 | }) 57 | 58 | envelope, _ := json.Marshal(map[string]interface{}{ 59 | "event": ClientBroadcastEvent, 60 | "channel": AnyCablePusherConfig.Channel, 61 | "data": string(msgBody), 62 | }) 63 | 64 | switch AnyCablePusherConfig.Backend { 65 | case "http": 66 | return apa.publishViaHTTP(envelope) 67 | case "redis": 68 | return apa.publishViaRedis(envelope) 69 | default: 70 | return fmt.Errorf("unknown AnyCable backend: %s", AnyCablePusherConfig.Backend) 71 | } 72 | } 73 | 74 | func (apa *AnyCablePusherAdapter) publishViaRedis(envelope []byte) error { 75 | pub := map[string]interface{}{ 76 | "stream": AnyCablePusherConfig.Channel, 77 | "data": string(envelope), 78 | } 79 | raw, _ := json.Marshal(pub) 80 | return apa.redis.Publish(context.Background(), "__anycable__", raw).Err() 81 | } 82 | 83 | func (apa *AnyCablePusherAdapter) publishViaHTTP(envelope []byte) error { 84 | pub := map[string]interface{}{ 85 | "stream": AnyCablePusherConfig.Channel, 86 | "data": string(envelope), 87 | } 88 | raw, _ := json.Marshal(pub) 89 | 90 | req, _ := http.NewRequest("POST", AnyCablePusherConfig.HTTPAddr, 91 | bytes.NewReader(raw)) 92 | req.Header.Set("Content-Type", "application/json") 93 | 94 | resp, err := http.DefaultClient.Do(req) 95 | if err != nil { 96 | return err 97 | } 98 | if resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusCreated { 99 | return fmt.Errorf("broadcast failed: %s", resp.Status) 100 | } 101 | return nil 102 | } 103 | 104 | func (apa *AnyCablePusherAdapter) Receive() (*serverSentMsg, error) { 105 | apa.muPending.Lock() 106 | if n := len(apa.pending); n > 0 { 107 | m := apa.pending[0] 108 | apa.pending = apa.pending[1:] 109 | apa.muPending.Unlock() 110 | return m, nil 111 | } 112 | apa.muPending.Unlock() 113 | 114 | for { 115 | msg, err := receiveIgnoringPing(apa.conn) 116 | if err != nil { 117 | return nil, err 118 | } 119 | 120 | message, err := parseBroadcast(msg, apa.socketID) 121 | if err != nil { 122 | return nil, err 123 | } 124 | if message == nil { 125 | continue 126 | } 127 | 128 | if message.Type == MsgServerBroadcastResult { 129 | apa.muPending.Lock() 130 | apa.pending = append(apa.pending, message) 131 | apa.muPending.Unlock() 132 | 133 | return &serverSentMsg{Type: MsgServerBroadcast, Payload: message.Payload}, nil 134 | } 135 | 136 | return message, nil 137 | } 138 | } 139 | 140 | func (apa *AnyCablePusherAdapter) EnsureConnected(ctx context.Context) error { 141 | apa.mu.Lock() 142 | defer apa.mu.Unlock() 143 | 144 | if apa.connected { 145 | return nil 146 | } 147 | 148 | res := make(chan error, 1) 149 | go func() { 150 | sockID, err := waitConnectionEstablished(apa.conn) 151 | if err != nil { 152 | res <- err 153 | return 154 | } 155 | apa.socketID = sockID 156 | 157 | subscribe := map[string]interface{}{ 158 | "event": SubscribeType, 159 | "data": map[string]string{ 160 | "channel": AnyCablePusherConfig.Channel, 161 | }, 162 | } 163 | 164 | if err := websocket.JSON.Send(apa.conn, subscribe); err != nil { 165 | res <- err 166 | return 167 | } 168 | 169 | if err := waitSubscriptionSucceeded(apa.conn, PusherConfig.Channel); err != nil { 170 | res <- err 171 | return 172 | } 173 | res <- nil 174 | }() 175 | 176 | select { 177 | case <-ctx.Done(): 178 | return errors.New("connection timeout exceeded") 179 | case err := <-res: 180 | return err 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /benchmark/action_cable_server_adapter.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "strconv" 9 | "sync" 10 | "time" 11 | 12 | "golang.org/x/net/websocket" 13 | ) 14 | 15 | var CableConfig struct { 16 | Channel string 17 | Encoding string 18 | } 19 | 20 | type ActionCableServerAdapter struct { 21 | conn *websocket.Conn 22 | connected bool 23 | mu sync.Mutex 24 | codec websocket.Codec 25 | } 26 | 27 | type acsaMsg struct { 28 | Type string `json:"type,omitempty"` 29 | Command string `json:"command,omitempty"` 30 | Identifier string `json:"identifier,omitempty"` 31 | Data string `json:"data,omitempty"` 32 | Message interface{} `json:"message,omitempty"` 33 | } 34 | 35 | func (acsa *ActionCableServerAdapter) Startup() error { 36 | acsa.connected = false 37 | 38 | if CableConfig.Encoding == "msgpack" { 39 | acsa.codec = MsgPackCodec 40 | } else if CableConfig.Encoding == "protobuf" { 41 | acsa.codec = ProtoBufCodec 42 | } else { 43 | acsa.codec = websocket.JSON 44 | } 45 | 46 | return nil 47 | } 48 | 49 | func (acsa *ActionCableServerAdapter) EnsureConnected(ctx context.Context) error { 50 | acsa.mu.Lock() 51 | defer acsa.mu.Unlock() 52 | 53 | if acsa.connected { 54 | return nil 55 | } 56 | 57 | resChan := make(chan error) 58 | 59 | go func() { 60 | welcomeMsg, err := acsa.receiveIgnoringPing() 61 | if err != nil { 62 | resChan <- err 63 | return 64 | } 65 | if welcomeMsg.Type != "welcome" { 66 | resChan <- fmt.Errorf("expected welcome msg, got %v", welcomeMsg) 67 | return 68 | } 69 | 70 | err = acsa.codec.Send(acsa.conn, &acsaMsg{ 71 | Command: "subscribe", 72 | Identifier: CableConfig.Channel, 73 | }) 74 | if err != nil { 75 | resChan <- err 76 | return 77 | } 78 | 79 | acsa.connected = true 80 | resChan <- nil 81 | }() 82 | 83 | select { 84 | case <-ctx.Done(): 85 | return errors.New("Connection timeout exceeded") 86 | case err := <-resChan: 87 | if err != nil { 88 | acsa.connected = true 89 | } 90 | return err 91 | } 92 | } 93 | 94 | func (acsa *ActionCableServerAdapter) SendEcho(payload *Payload) error { 95 | if !acsa.connected { 96 | ctx, cancel := context.WithTimeout(context.Background(), ConnectionTimeout) 97 | defer cancel() 98 | 99 | err := acsa.EnsureConnected(ctx) 100 | 101 | if err != nil { 102 | return err 103 | } 104 | } 105 | 106 | data, err := json.Marshal(map[string]interface{}{"action": "echo", "payload": payloadTojsonPayload(payload)}) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | return acsa.codec.Send(acsa.conn, &acsaMsg{ 112 | Command: "message", 113 | Identifier: CableConfig.Channel, 114 | Data: string(data), 115 | }) 116 | } 117 | 118 | func (acsa *ActionCableServerAdapter) SendBroadcast(payload *Payload) error { 119 | if !acsa.connected { 120 | ctx, cancel := context.WithTimeout(context.Background(), ConnectionTimeout) 121 | defer cancel() 122 | 123 | err := acsa.EnsureConnected(ctx) 124 | 125 | if err != nil { 126 | return err 127 | } 128 | } 129 | 130 | data, err := json.Marshal(map[string]interface{}{"action": "broadcast", "payload": payloadTojsonPayload(payload)}) 131 | if err != nil { 132 | return err 133 | } 134 | 135 | return acsa.codec.Send(acsa.conn, &acsaMsg{ 136 | Command: "message", 137 | Identifier: CableConfig.Channel, 138 | Data: string(data), 139 | }) 140 | } 141 | 142 | func (acsa *ActionCableServerAdapter) Receive() (*serverSentMsg, error) { 143 | if !acsa.connected { 144 | ctx, cancel := context.WithTimeout(context.Background(), ConnectionTimeout) 145 | defer cancel() 146 | 147 | err := acsa.EnsureConnected(ctx) 148 | 149 | if err != nil { 150 | return nil, err 151 | } 152 | } 153 | 154 | msg, err := acsa.receiveIgnoringPing() 155 | if err != nil { 156 | return nil, err 157 | } 158 | 159 | if msg.Message == nil { 160 | panic(fmt.Errorf("Message is nil for %v", msg)) 161 | } 162 | 163 | message := msg.Message.(map[string]interface{}) 164 | payloadMap := message["payload"].(map[string]interface{}) 165 | 166 | payload := &Payload{} 167 | unixNanosecond, err := strconv.ParseInt(payloadMap["sendTime"].(string), 10, 64) 168 | if err != nil { 169 | return nil, err 170 | } 171 | payload.SendTime = time.Unix(0, unixNanosecond) 172 | 173 | if padding, ok := payloadMap["padding"]; ok { 174 | paddingJson, err := json.Marshal(padding) 175 | if err != nil { 176 | return nil, err 177 | } 178 | payload.Padding = paddingJson 179 | } 180 | 181 | msgType, err := ParseMessageType(message["action"].(string)) 182 | if err != nil { 183 | return nil, err 184 | } 185 | 186 | return &serverSentMsg{Type: msgType, Payload: payload}, nil 187 | } 188 | 189 | func (acsa *ActionCableServerAdapter) receiveIgnoringPing() (*acsaMsg, error) { 190 | for { 191 | var msg acsaMsg 192 | err := acsa.codec.Receive(acsa.conn, &msg) 193 | if err != nil { 194 | return nil, err 195 | } 196 | 197 | if msg.Type == "ping" || msg.Type == "confirm_subscription" { 198 | continue 199 | } 200 | 201 | if msg.Type == "reject_subscription" { 202 | return nil, errors.New("Subscription rejected") 203 | } 204 | 205 | return &msg, nil 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /etc/chart.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'erb' 3 | 4 | class Measurment 5 | class Point < Struct.new(:clients, :max, :min, :median, :p95, keyword_init: true) 6 | end 7 | 8 | class Step 9 | include Comparable 10 | 11 | attr_reader :num, :points 12 | 13 | def initialize(num) 14 | @num = num 15 | @points = [] 16 | end 17 | 18 | def <<(point) 19 | points << point 20 | end 21 | 22 | def <=>(other) 23 | num <=> other.num 24 | end 25 | 26 | def max 27 | mean(:max) 28 | end 29 | 30 | def p95 31 | mean(:p95) 32 | end 33 | 34 | def median 35 | mean(:median) 36 | end 37 | 38 | private 39 | 40 | def mean(field) 41 | sum = points.sum { _1[field].to_f } 42 | 43 | sum / points.size 44 | end 45 | end 46 | 47 | attr_reader :type, :steps 48 | 49 | def initialize(type) 50 | @type = type 51 | @steps = Hash.new { |h, k| h[k] = Step.new(k) } 52 | end 53 | 54 | def <<(point_data) 55 | point = Point.new( 56 | clients: point_data["clients"], 57 | max: point_data["max-rtt"], 58 | min: point_data["min-rtt"], 59 | median: point_data["median-rtt"], 60 | p95: point_data["per-rtt"] 61 | ) 62 | steps[point.clients] << point 63 | end 64 | end 65 | 66 | data = Hash.new { |h,k| h[k] = Measurment.new(k) } 67 | 68 | target_dir = ARGV[0] || File.join(__dir__, "../dist") 69 | 70 | Dir.glob(File.join(target_dir, "*.json")).each do |report| 71 | matches = report.match(/\/([^\/]+)\_\d+.json/) 72 | next puts "Unknown file: #{report}" unless matches 73 | 74 | report_type = matches[1] 75 | report_data = JSON.parse(File.read(report)) 76 | 77 | report_data["steps"].each do |step| 78 | data[report_type] << step 79 | end 80 | end 81 | 82 | series = {} 83 | %i[median p95 max].each do |field| 84 | data.map do |k, v| 85 | "{\n" + 86 | "name: '#{k}',\n" + 87 | "data: #{v.steps.values.map(&field)}\n" + 88 | "}\n" 89 | end.join(",\n").then { "[#{_1}]\n" }.then { series[field] = _1 } 90 | end 91 | 92 | renderer = ERB.new(DATA.read) 93 | 94 | io = 95 | if ARGV[1] 96 | File.open(ARGV[1], "w") 97 | else 98 | STDOUT 99 | end 100 | 101 | io.puts renderer.result(binding) 102 | 103 | __END__ 104 | 105 | 106 | 107 | 108 | WebSocket bench results 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 155 | 156 | 157 |
158 |
159 |
160 |
161 |
162 | 163 | 221 | 222 | 223 | -------------------------------------------------------------------------------- /benchmark/benchmark.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "math/rand" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "github.com/cheggaaa/pb/v3" 12 | ) 13 | 14 | const ( 15 | ConnectionTimeout = 5 * time.Minute 16 | ) 17 | 18 | type Benchmark struct { 19 | errChan chan error 20 | rttResultChan chan time.Duration 21 | 22 | payloadPadding []byte 23 | 24 | Config 25 | 26 | clients []Client 27 | } 28 | 29 | type Config struct { 30 | WebsocketURL string 31 | WebsocketOrigin string 32 | WebsocketProtocol string 33 | ServerType string 34 | ClientCmd int 35 | PayloadPaddingSize int 36 | InitialClients int 37 | StepSize int 38 | Concurrent int 39 | ConcurrentConnect int 40 | SampleSize int 41 | LimitPercentile int 42 | LimitRTT time.Duration 43 | TotalSteps int 44 | Interactive bool 45 | StepDelay time.Duration 46 | CommandDelay time.Duration 47 | CommandDelayChance int 48 | WaitBroadcastsSeconds int 49 | ClientPools []ClientPool 50 | ResultRecorder ResultRecorder 51 | } 52 | 53 | func New(config *Config) *Benchmark { 54 | b := &Benchmark{Config: *config} 55 | 56 | b.errChan = make(chan error) 57 | b.rttResultChan = make(chan time.Duration) 58 | 59 | b.payloadPadding = []byte(strings.Repeat( 60 | "1234567890", 61 | b.PayloadPaddingSize/10+1, 62 | )[:b.PayloadPaddingSize]) 63 | 64 | return b 65 | } 66 | 67 | func (b *Benchmark) Run() error { 68 | var expectedRxBroadcastCount int 69 | 70 | if b.InitialClients == 0 { 71 | b.InitialClients = b.StepSize 72 | } 73 | 74 | b.startClients(b.ServerType, b.InitialClients, b.ConcurrentConnect) 75 | 76 | stepNum := 0 77 | drop := 0 78 | 79 | finished := false 80 | 81 | for { 82 | 83 | stepNum++ 84 | 85 | stepDrop := 0 86 | 87 | bar := pb.StartNew(b.SampleSize) 88 | 89 | inProgress := 0 90 | for i := 0; i < b.Concurrent; i++ { 91 | if err := b.sendToRandomClient(); err != nil { 92 | return err 93 | } 94 | inProgress++ 95 | } 96 | 97 | var rttAgg rttAggregate 98 | for rttAgg.Count()+stepDrop < b.SampleSize { 99 | select { 100 | case result := <-b.rttResultChan: 101 | rttAgg.Add(result) 102 | bar.Increment() 103 | inProgress-- 104 | case err := <-b.errChan: 105 | stepDrop++ 106 | debug(fmt.Sprintf("error: %v", err)) 107 | } 108 | 109 | if rttAgg.Count()+inProgress+stepDrop < b.SampleSize { 110 | if err := b.sendToRandomClient(); err != nil { 111 | return err 112 | } 113 | inProgress++ 114 | } 115 | } 116 | 117 | bar.Finish() 118 | 119 | drop += stepDrop 120 | 121 | expectedRxBroadcastCount += (len(b.clients) - drop) * b.SampleSize 122 | 123 | if (b.TotalSteps > 0 && b.TotalSteps == stepNum) || (b.TotalSteps == 0 && b.LimitRTT < rttAgg.Percentile(b.LimitPercentile)) { 124 | finished = true 125 | 126 | if b.ClientCmd == ClientBroadcastCmd { 127 | // Due to the async nature of the broadcasts and the receptions, it is 128 | // possible for the broadcastResult to arrive before all the 129 | // broadcasts. This isn't really a problem when running the benchmark 130 | // because the samples are will balance each other out. However, it 131 | // does matter when checking at the end that all expected broadcasts 132 | // were received. So we wait a little before getting the broadcast 133 | // count. 134 | time.Sleep(time.Duration(b.WaitBroadcastsSeconds) * time.Second) 135 | 136 | totalRxBroadcastCount := 0 137 | for _, c := range b.clients { 138 | count, err := c.ResetRxBroadcastCount() 139 | if err != nil { 140 | return err 141 | } 142 | totalRxBroadcastCount += count 143 | } 144 | if totalRxBroadcastCount < expectedRxBroadcastCount { 145 | b.ResultRecorder.Message( 146 | fmt.Sprintf("Missing received broadcasts: expected %d, got %d", expectedRxBroadcastCount, totalRxBroadcastCount), 147 | ) 148 | } 149 | if totalRxBroadcastCount > expectedRxBroadcastCount { 150 | b.ResultRecorder.Message( 151 | fmt.Sprintf("Extra received broadcasts: expected %d, got %d", expectedRxBroadcastCount, totalRxBroadcastCount), 152 | ) 153 | } 154 | } 155 | } 156 | 157 | err := b.ResultRecorder.Record( 158 | len(b.clients)-drop, 159 | b.LimitPercentile, 160 | rttAgg.Percentile(b.LimitPercentile), 161 | rttAgg.Min(), 162 | rttAgg.Percentile(50), 163 | rttAgg.Max(), 164 | ) 165 | if err != nil { 166 | return err 167 | } 168 | 169 | if finished { 170 | return nil 171 | } 172 | 173 | if b.Interactive { 174 | promptToContinue() 175 | } 176 | 177 | if b.StepDelay > 0 { 178 | time.Sleep(b.StepDelay) 179 | } 180 | 181 | b.startClients(b.ServerType, b.StepSize, b.ConcurrentConnect) 182 | } 183 | } 184 | 185 | func (b *Benchmark) startClients(serverType string, total int, concurrent int) { 186 | bar := pb.Simple.Start(total) 187 | created := 0 188 | counter := len(b.clients) 189 | 190 | for created < total { 191 | var waitgroup sync.WaitGroup 192 | var mu sync.Mutex 193 | 194 | toCreate := int(math.Min(float64(concurrent), float64(total-created))) 195 | 196 | for i := 0; i < toCreate; i++ { 197 | waitgroup.Add(1) 198 | 199 | go func() { 200 | cp := b.ClientPools[i%len(b.ClientPools)] 201 | client, err := cp.New(counter, b.WebsocketURL, b.WebsocketOrigin, b.ServerType, b.rttResultChan, b.errChan, b.payloadPadding) 202 | 203 | if err != nil { 204 | b.errChan <- err 205 | } 206 | mu.Lock() 207 | b.clients = append(b.clients, client) 208 | bar.Increment() 209 | mu.Unlock() 210 | waitgroup.Done() 211 | }() 212 | } 213 | waitgroup.Wait() 214 | created += toCreate 215 | } 216 | 217 | bar.Finish() 218 | } 219 | 220 | func (b *Benchmark) randomClient() Client { 221 | if len(b.clients) == 0 { 222 | panic("no clients") 223 | } 224 | 225 | return b.clients[rand.Intn(len(b.clients))] 226 | } 227 | 228 | func (b *Benchmark) sendToRandomClient() error { 229 | if len(b.clients) == 0 { 230 | panic("no clients") 231 | } 232 | 233 | if b.CommandDelay > 0 && b.CommandDelayChance > rand.Intn(100) { 234 | time.Sleep(b.CommandDelay) 235 | } 236 | 237 | client := b.randomClient() 238 | switch b.ClientCmd { 239 | case ClientEchoCmd: 240 | if err := client.SendEcho(); err != nil { 241 | return err 242 | } 243 | case ClientBroadcastCmd: 244 | if err := client.SendBroadcast(); err != nil { 245 | return err 246 | } 247 | default: 248 | panic("unknown client command") 249 | } 250 | 251 | return nil 252 | } 253 | 254 | func promptToContinue() { 255 | fmt.Print("Press any key to continue to the next step") 256 | var input string 257 | fmt.Scanln(&input) 258 | } 259 | 260 | func printNow(label string) { 261 | fmt.Printf("[%s] %s\n", time.Now().Format(time.RFC3339), label) 262 | } 263 | 264 | func debug(msg string) { 265 | fmt.Printf("DEBUG [%s] %s\n", time.Now().Format(time.RFC3339), msg) 266 | } 267 | -------------------------------------------------------------------------------- /benchmark/local_client.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "io" 7 | "net" 8 | "sync" 9 | "time" 10 | 11 | "golang.org/x/net/websocket" 12 | ) 13 | 14 | var RemoteAddr struct { 15 | Addr *net.TCPAddr 16 | Host string 17 | Config *websocket.Config 18 | Secure bool 19 | } 20 | 21 | const ( 22 | MsgServerEcho = 'e' 23 | MsgServerBroadcast = 'b' 24 | MsgServerBroadcastResult = 'r' 25 | MsgClientEcho = 'e' 26 | MsgClientBroadcast = 'b' 27 | ) 28 | 29 | type localClient struct { 30 | conn *websocket.Conn 31 | config *websocket.Config 32 | laddr *net.TCPAddr 33 | dest string 34 | origin string 35 | serverType string 36 | serverAdapter ServerAdapter 37 | rttResultChan chan<- time.Duration 38 | errChan chan<- error 39 | payloadPadding []byte 40 | 41 | rxBroadcastCountLock sync.Mutex 42 | rxBroadcastCount int 43 | } 44 | 45 | type ServerAdapter interface { 46 | SendEcho(payload *Payload) error 47 | SendBroadcast(payload *Payload) error 48 | Receive() (*serverSentMsg, error) 49 | } 50 | 51 | type Payload struct { 52 | SendTime time.Time 53 | Padding []byte 54 | } 55 | 56 | type jsonPayload struct { 57 | SendTime string `json:"sendTime"` 58 | Padding interface{} `json:"padding,omitempty"` 59 | } 60 | 61 | // serverSentMsg includes all fields that can be in server sent message 62 | type serverSentMsg struct { 63 | Type byte 64 | Payload *Payload 65 | ListenerCount int 66 | } 67 | 68 | type jsonServerSentMsg struct { 69 | Type string `json:"type"` 70 | Payload *jsonPayload `json:"payload"` 71 | ListenerCount int `json:"listenerCount"` 72 | } 73 | 74 | func newLocalClient( 75 | laddr *net.TCPAddr, 76 | dest, origin, serverType string, 77 | rttResultChan chan<- time.Duration, 78 | errChan chan error, 79 | padding []byte, 80 | ) (*localClient, error) { 81 | if origin == "" { 82 | origin = dest 83 | } 84 | 85 | c := &localClient{ 86 | laddr: laddr, 87 | dest: dest, 88 | origin: origin, 89 | rttResultChan: rttResultChan, 90 | errChan: errChan, 91 | payloadPadding: padding, 92 | } 93 | 94 | tcpConn, err := net.DialTCP("tcp", c.laddr, RemoteAddr.Addr) 95 | if err != nil { 96 | panic(err) 97 | } 98 | 99 | var conn io.ReadWriteCloser 100 | 101 | if RemoteAddr.Secure { 102 | conn = tls.Client(tcpConn, &tls.Config{ 103 | InsecureSkipVerify: true, 104 | ServerName: RemoteAddr.Host, 105 | }) 106 | } else { 107 | conn = tcpConn 108 | } 109 | 110 | initTime := time.Now() 111 | 112 | c.conn, err = websocket.NewClient(RemoteAddr.Config, conn) 113 | if err != nil { 114 | return nil, err 115 | } 116 | 117 | c.conn.MaxPayloadBytes = 1000000 118 | 119 | switch serverType { 120 | case "json": 121 | c.serverAdapter = &StandardServerAdapter{conn: c.conn} 122 | case "binary": 123 | c.serverAdapter = &BinaryServerAdapter{conn: c.conn} 124 | case "actioncable": 125 | acsa := &ActionCableServerAdapter{conn: c.conn} 126 | err = acsa.Startup() 127 | if err != nil { 128 | return nil, err 129 | } 130 | c.serverAdapter = acsa 131 | case "actioncable-connect": 132 | acsa := &ActionCableServerConnectAdapter{conn: c.conn} 133 | err = acsa.Startup() 134 | if err != nil { 135 | return nil, err 136 | } 137 | err = acsa.Connected(initTime) 138 | if err != nil { 139 | return nil, err 140 | } 141 | c.serverAdapter = acsa 142 | case "pusher": 143 | pusherAdapter := &PusherServerAdapter{conn: c.conn} 144 | err = pusherAdapter.Startup() 145 | if err != nil { 146 | return nil, err 147 | } 148 | c.serverAdapter = pusherAdapter 149 | case "anycable-pusher": 150 | anyCablePusherAdapter := &AnyCablePusherAdapter{conn: c.conn} 151 | err = anyCablePusherAdapter.Startup() 152 | if err != nil { 153 | return nil, err 154 | } 155 | c.serverAdapter = anyCablePusherAdapter 156 | case "pusher-connect", "anycable-connect": 157 | skip := serverType == "anycable-connect" 158 | psca := &PusherServerConnectAdapter{ 159 | conn: c.conn, 160 | skipUnsubscribe: skip, 161 | } 162 | err = psca.Startup() 163 | if err != nil { 164 | return nil, err 165 | } 166 | err = psca.Connected(initTime) 167 | if err != nil { 168 | return nil, err 169 | } 170 | c.serverAdapter = psca 171 | case "phoenix": 172 | psa := &PhoenixServerAdapter{conn: c.conn} 173 | err = psa.Startup() 174 | if err != nil { 175 | return nil, err 176 | } 177 | c.serverAdapter = psa 178 | default: 179 | return nil, fmt.Errorf("Unknown server type: %v", serverType) 180 | } 181 | 182 | go c.rx() 183 | 184 | return c, nil 185 | } 186 | 187 | func (c *localClient) SendEcho() error { 188 | return c.serverAdapter.SendEcho(&Payload{SendTime: time.Now(), Padding: c.payloadPadding}) 189 | } 190 | 191 | func (c *localClient) SendBroadcast() error { 192 | return c.serverAdapter.SendBroadcast(&Payload{SendTime: time.Now(), Padding: c.payloadPadding}) 193 | } 194 | 195 | func (c *localClient) ResetRxBroadcastCount() (int, error) { 196 | c.rxBroadcastCountLock.Lock() 197 | count := c.rxBroadcastCount 198 | c.rxBroadcastCount = 0 199 | c.rxBroadcastCountLock.Unlock() 200 | return count, nil 201 | } 202 | 203 | func (c *localClient) rx() { 204 | for { 205 | msg, err := c.serverAdapter.Receive() 206 | if err != nil { 207 | c.errChan <- err 208 | return 209 | } 210 | 211 | switch msg.Type { 212 | case MsgServerEcho, MsgServerBroadcastResult: 213 | if msg.Payload != nil { 214 | rtt := time.Now().Sub(msg.Payload.SendTime) 215 | c.rttResultChan <- rtt 216 | } else { 217 | c.errChan <- fmt.Errorf("received unparsable %s payload: %v", msg.Type, msg.Payload) 218 | return 219 | } 220 | case MsgServerBroadcast: 221 | c.rxBroadcastCountLock.Lock() 222 | c.rxBroadcastCount++ 223 | c.rxBroadcastCountLock.Unlock() 224 | default: 225 | c.errChan <- fmt.Errorf("received unknown message type: %v", msg.Type) 226 | return 227 | } 228 | } 229 | } 230 | 231 | type LocalClientPool struct { 232 | laddr *net.TCPAddr 233 | clients map[int]*localClient 234 | mu sync.Mutex 235 | } 236 | 237 | func NewLocalClientPool(laddr *net.TCPAddr) *LocalClientPool { 238 | return &LocalClientPool{ 239 | laddr: laddr, 240 | clients: make(map[int]*localClient), 241 | } 242 | } 243 | 244 | func (lcp *LocalClientPool) New( 245 | id int, 246 | dest, origin, serverType string, 247 | rttResultChan chan time.Duration, 248 | errChan chan error, 249 | padding []byte, 250 | ) (Client, error) { 251 | c, err := newLocalClient(lcp.laddr, dest, origin, serverType, rttResultChan, errChan, padding) 252 | if err != nil { 253 | return nil, err 254 | } 255 | 256 | lcp.mu.Lock() 257 | lcp.clients[id] = c 258 | lcp.mu.Unlock() 259 | 260 | return c, nil 261 | } 262 | 263 | func (lcp *LocalClientPool) Close() error { 264 | for _, c := range lcp.clients { 265 | if err := c.conn.Close(); err != nil { 266 | return err 267 | } 268 | } 269 | 270 | return nil 271 | } 272 | -------------------------------------------------------------------------------- /action_cable/action_cable.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // source: action_cable.proto 3 | 4 | package action_cable 5 | 6 | import ( 7 | fmt "fmt" 8 | proto "github.com/golang/protobuf/proto" 9 | math "math" 10 | ) 11 | 12 | // Reference imports to suppress errors if they are not otherwise used. 13 | var _ = proto.Marshal 14 | var _ = fmt.Errorf 15 | var _ = math.Inf 16 | 17 | // This is a compile-time assertion to ensure that this generated file 18 | // is compatible with the proto package it is being compiled against. 19 | // A compilation error at this line likely means your copy of the 20 | // proto package needs to be updated. 21 | const _ = proto.ProtoPackageIsVersion3 // please upgrade the proto package 22 | 23 | type Type int32 24 | 25 | const ( 26 | Type_no_type Type = 0 27 | Type_welcome Type = 1 28 | Type_disconnect Type = 2 29 | Type_ping Type = 3 30 | Type_confirm_subscription Type = 4 31 | Type_reject_subscription Type = 5 32 | ) 33 | 34 | var Type_name = map[int32]string{ 35 | 0: "no_type", 36 | 1: "welcome", 37 | 2: "disconnect", 38 | 3: "ping", 39 | 4: "confirm_subscription", 40 | 5: "reject_subscription", 41 | } 42 | 43 | var Type_value = map[string]int32{ 44 | "no_type": 0, 45 | "welcome": 1, 46 | "disconnect": 2, 47 | "ping": 3, 48 | "confirm_subscription": 4, 49 | "reject_subscription": 5, 50 | } 51 | 52 | func (x Type) String() string { 53 | return proto.EnumName(Type_name, int32(x)) 54 | } 55 | 56 | func (Type) EnumDescriptor() ([]byte, []int) { 57 | return fileDescriptor_75ae909d4f019479, []int{0} 58 | } 59 | 60 | type Command int32 61 | 62 | const ( 63 | Command_unknown_command Command = 0 64 | Command_subscribe Command = 1 65 | Command_unsubscribe Command = 2 66 | Command_message Command = 3 67 | ) 68 | 69 | var Command_name = map[int32]string{ 70 | 0: "unknown_command", 71 | 1: "subscribe", 72 | 2: "unsubscribe", 73 | 3: "message", 74 | } 75 | 76 | var Command_value = map[string]int32{ 77 | "unknown_command": 0, 78 | "subscribe": 1, 79 | "unsubscribe": 2, 80 | "message": 3, 81 | } 82 | 83 | func (x Command) String() string { 84 | return proto.EnumName(Command_name, int32(x)) 85 | } 86 | 87 | func (Command) EnumDescriptor() ([]byte, []int) { 88 | return fileDescriptor_75ae909d4f019479, []int{1} 89 | } 90 | 91 | type Message struct { 92 | Type Type `protobuf:"varint,1,opt,name=type,proto3,enum=action_cable.Type" json:"type,omitempty"` 93 | Command Command `protobuf:"varint,2,opt,name=command,proto3,enum=action_cable.Command" json:"command,omitempty"` 94 | Identifier string `protobuf:"bytes,3,opt,name=identifier,proto3" json:"identifier,omitempty"` 95 | Data string `protobuf:"bytes,4,opt,name=data,proto3" json:"data,omitempty"` 96 | Message []byte `protobuf:"bytes,5,opt,name=message,proto3" json:"message,omitempty"` 97 | Reason string `protobuf:"bytes,6,opt,name=reason,proto3" json:"reason,omitempty"` 98 | Reconnect bool `protobuf:"varint,7,opt,name=reconnect,proto3" json:"reconnect,omitempty"` 99 | XXX_NoUnkeyedLiteral struct{} `json:"-"` 100 | XXX_unrecognized []byte `json:"-"` 101 | XXX_sizecache int32 `json:"-"` 102 | } 103 | 104 | func (m *Message) Reset() { *m = Message{} } 105 | func (m *Message) String() string { return proto.CompactTextString(m) } 106 | func (*Message) ProtoMessage() {} 107 | func (*Message) Descriptor() ([]byte, []int) { 108 | return fileDescriptor_75ae909d4f019479, []int{0} 109 | } 110 | 111 | func (m *Message) XXX_Unmarshal(b []byte) error { 112 | return xxx_messageInfo_Message.Unmarshal(m, b) 113 | } 114 | func (m *Message) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { 115 | return xxx_messageInfo_Message.Marshal(b, m, deterministic) 116 | } 117 | func (m *Message) XXX_Merge(src proto.Message) { 118 | xxx_messageInfo_Message.Merge(m, src) 119 | } 120 | func (m *Message) XXX_Size() int { 121 | return xxx_messageInfo_Message.Size(m) 122 | } 123 | func (m *Message) XXX_DiscardUnknown() { 124 | xxx_messageInfo_Message.DiscardUnknown(m) 125 | } 126 | 127 | var xxx_messageInfo_Message proto.InternalMessageInfo 128 | 129 | func (m *Message) GetType() Type { 130 | if m != nil { 131 | return m.Type 132 | } 133 | return Type_no_type 134 | } 135 | 136 | func (m *Message) GetCommand() Command { 137 | if m != nil { 138 | return m.Command 139 | } 140 | return Command_unknown_command 141 | } 142 | 143 | func (m *Message) GetIdentifier() string { 144 | if m != nil { 145 | return m.Identifier 146 | } 147 | return "" 148 | } 149 | 150 | func (m *Message) GetData() string { 151 | if m != nil { 152 | return m.Data 153 | } 154 | return "" 155 | } 156 | 157 | func (m *Message) GetMessage() []byte { 158 | if m != nil { 159 | return m.Message 160 | } 161 | return nil 162 | } 163 | 164 | func (m *Message) GetReason() string { 165 | if m != nil { 166 | return m.Reason 167 | } 168 | return "" 169 | } 170 | 171 | func (m *Message) GetReconnect() bool { 172 | if m != nil { 173 | return m.Reconnect 174 | } 175 | return false 176 | } 177 | 178 | func init() { 179 | proto.RegisterEnum("action_cable.Type", Type_name, Type_value) 180 | proto.RegisterEnum("action_cable.Command", Command_name, Command_value) 181 | proto.RegisterType((*Message)(nil), "action_cable.Message") 182 | } 183 | 184 | func init() { proto.RegisterFile("action_cable.proto", fileDescriptor_75ae909d4f019479) } 185 | 186 | var fileDescriptor_75ae909d4f019479 = []byte{ 187 | // 307 bytes of a gzipped FileDescriptorProto 188 | 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x54, 0x91, 0xcd, 0x6e, 0xc2, 0x30, 189 | 0x10, 0x84, 0x31, 0x04, 0x02, 0x0b, 0x05, 0x6b, 0xe9, 0x8f, 0x0f, 0x55, 0x15, 0xf5, 0x50, 0x45, 190 | 0x1c, 0xa8, 0xd4, 0x3e, 0x42, 0x8f, 0x55, 0x2f, 0x51, 0xef, 0x91, 0xe3, 0x2c, 0xc8, 0x2d, 0xb1, 191 | 0x23, 0xc7, 0x08, 0xf1, 0xc2, 0x7d, 0x8e, 0x2a, 0x21, 0x08, 0x72, 0xcb, 0xcc, 0x7c, 0xab, 0xec, 192 | 0xac, 0x01, 0xa5, 0xf2, 0xda, 0x9a, 0x54, 0xc9, 0x6c, 0x47, 0xeb, 0xd2, 0x59, 0x6f, 0x71, 0x76, 193 | 0xed, 0x3d, 0xff, 0x31, 0x08, 0xbf, 0xa8, 0xaa, 0xe4, 0x96, 0xf0, 0x05, 0x02, 0x7f, 0x2c, 0x49, 194 | 0xb0, 0x88, 0xc5, 0xf3, 0x37, 0x5c, 0x77, 0x86, 0xbf, 0x8f, 0x25, 0x25, 0x4d, 0x8e, 0xaf, 0x10, 195 | 0x2a, 0x5b, 0x14, 0xd2, 0xe4, 0xa2, 0xdf, 0xa0, 0x77, 0x5d, 0xf4, 0xe3, 0x14, 0x26, 0x67, 0x0a, 196 | 0x9f, 0x00, 0x74, 0x4e, 0xc6, 0xeb, 0x8d, 0x26, 0x27, 0x06, 0x11, 0x8b, 0x27, 0xc9, 0x95, 0x83, 197 | 0x08, 0x41, 0x2e, 0xbd, 0x14, 0x41, 0x93, 0x34, 0xdf, 0x28, 0x20, 0x2c, 0x4e, 0x7b, 0x89, 0x61, 198 | 0xc4, 0xe2, 0x59, 0x72, 0x96, 0x78, 0x0f, 0x23, 0x47, 0xb2, 0xb2, 0x46, 0x8c, 0x1a, 0xbe, 0x55, 199 | 0xf8, 0x08, 0x13, 0x47, 0xca, 0x1a, 0x43, 0xca, 0x8b, 0x30, 0x62, 0xf1, 0x38, 0xb9, 0x18, 0xab, 200 | 0x02, 0x82, 0xba, 0x02, 0x4e, 0x21, 0x34, 0x36, 0xad, 0x7b, 0xf0, 0x5e, 0x2d, 0x0e, 0xb4, 0x53, 201 | 0xb6, 0x20, 0xce, 0x70, 0x0e, 0x90, 0xeb, 0xaa, 0xe5, 0x79, 0x1f, 0xc7, 0x10, 0x94, 0xda, 0x6c, 202 | 0xf9, 0x00, 0x05, 0xdc, 0x2a, 0x6b, 0x36, 0xda, 0x15, 0x69, 0xb5, 0xcf, 0x2a, 0xe5, 0x74, 0x59, 203 | 0xd7, 0xe5, 0x01, 0x3e, 0xc0, 0xd2, 0xd1, 0x0f, 0x29, 0xdf, 0x0d, 0x86, 0xab, 0x4f, 0x08, 0xdb, 204 | 0x33, 0xe0, 0x12, 0x16, 0x7b, 0xf3, 0x6b, 0xec, 0xc1, 0xa4, 0xed, 0x41, 0x78, 0x0f, 0x6f, 0x60, 205 | 0xd2, 0x4e, 0x64, 0xf5, 0xbf, 0x17, 0x30, 0xdd, 0x9b, 0x8b, 0xd1, 0xaf, 0x37, 0x6b, 0xfb, 0xf2, 206 | 0x41, 0x36, 0x6a, 0x5e, 0xee, 0xfd, 0x3f, 0x00, 0x00, 0xff, 0xff, 0x40, 0xa4, 0x2b, 0x26, 0xcf, 207 | 0x01, 0x00, 0x00, 208 | } 209 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # websocket-bench 2 | 3 | CLI interface for benchmarking WebSocket servers 4 | 5 | ## Overview 6 | websocket-bench allows you to perform load testing on WebSocket servers of various types, including standard WebSocket servers, ActionCable, Phoenix, Pusher, and AnyCable. 7 | 8 | This project is inspired by the [websocket-shootout](https://github.com/hashrocket/websocket-shootout) project. 9 | 10 | ## Installation 11 | 12 | ### Prerequisites 13 | 14 | - Go 1.16 or higher 15 | 16 | ### Building from source 17 | 18 | ```bash 19 | git clone https://github.com/anycable/websocket-bench.git 20 | cd websocket-bench 21 | 22 | # Build the binary 23 | go build -o websocket-bench 24 | ``` 25 | 26 | ## Usage 27 | 28 | websocket-bench provides three main benchmark commands: 29 | 30 | ### Echo Benchmark 31 | 32 | Tests 1-to-1 performance by sending messages to the server and measuring how long it takes for the server to echo them back. 33 | 34 | ```bash 35 | ./websocket-bench echo [options] ws://server-url 36 | ``` 37 | 38 | Common options: 39 | - `-c, --concurrent`: Number of concurrent echo requests (default: 50) 40 | - `-s, --sample-size`: Number of echoes in a sample (default: 10000) 41 | - `--step-size`: Number of clients to increase each step (default: 5000) 42 | - `--server-type`: Server type to connect to (json, binary, actioncable, phoenix) (default: json) 43 | - `-f, --format`: Output format (json or text) 44 | - `-n, --filename`: Output filename 45 | 46 | ### Broadcast Benchmark 47 | 48 | Tests 1-to-many performance by sending broadcast messages and measuring how long it takes for all clients to receive them. 49 | 50 | ```bash 51 | ./websocket-bench broadcast [options] ws://server-url 52 | ``` 53 | 54 | Common options: 55 | - `-c, --concurrent`: Number of concurrent broadcast requests (default: 4) 56 | - `--connect-concurrent`: Concurrent connection initialization requests (default: 100) 57 | - `-s, --sample-size`: Number of broadcasts in a sample (default: 20) 58 | - `--initial-clients`: Initial number of clients (default: 0) 59 | - `--step-size`: Number of clients to increase each step (default: 5000) 60 | - `--server-type`: Server type (`json`, `binary`, `actioncable`, `phoenix`, `pusher`, `anycable-pusher`) (default: `json`) 61 | - `-f, --format`: Output format (`json` or `text`) 62 | - `-n, --filename`: Output filename 63 | 64 | **Pusher‑compatible flags** (for `pusher` and `anycable-pusher`): 65 | - `--pusher-app-id`: Pusher App ID (default: `app-id`) 66 | - `--pusher-app-key`: Pusher App Key (default: `app-key`) 67 | - `--pusher-app-secret`: Pusher App Secret (default: `app-secret`) 68 | - `--pusher-channel`: Channel name (default: `private-benchmark`) 69 | - `--pusher-host`: HTTP trigger host (default: `127.0.0.1`) 70 | - `--pusher-port`: HTTP trigger port (default: `6001`) 71 | 72 | **Additional flags for `anycable-pusher` only**: 73 | - `--anycable-backend`: Broadcast transport (`http` or `redis`) (default: `http`) 74 | - `--anycable-http-endpoint`: AnyCable HTTP broadcast endpoint (default: `http://localhost:8080/_broadcast`) 75 | - `--anycable-redis-addr`: Redis address for broadcasts (default: `127.0.0.1:6379`) 76 | 77 | ### Connect Benchmark 78 | 79 | Tests connection initialization performance by measuring how long it takes to establish WebSocket connections. 80 | 81 | ```bash 82 | ./websocket-bench connect [options] ws://server-url 83 | ``` 84 | 85 | Common options: 86 | - `-c, --concurrent`: Number of concurrent connection requests (default: 50) 87 | - `--step-size`: Number of clients to connect at each step (default: 5000) 88 | - `--server-type`: Server type to connect to (default: json) 89 | - `actioncable-connect`: Test connection to ActionCable servers 90 | - `pusher-connect`: Test connection to Pusher-compatible servers (with channel subscription) 91 | - `anycable-connect`: Test connection to AnyCable (similar to pusher-connect, but optimized for AnyCable) 92 | - `-f, --format`: Output format (json or text) 93 | - `-n, --filename`: Output filename 94 | - `--pusher-channel`: Pusher channel name for connect tests (default Benchmark) 95 | 96 | ### Worker Mode 97 | 98 | Worker mode allows you to distribute connections across multiple machines for scaling tests to a large number of connections. 99 | 100 | ```bash 101 | ./websocket-bench worker [options] 102 | ``` 103 | 104 | Options: 105 | - `-a, --address`: Address to listen on (default: 0.0.0.0) 106 | - `-p, --port`: Port to listen on (default: 3000) 107 | 108 | When using worker mode, you can run the master process with the `--worker-addr` option to specify worker node addresses separated by commas. 109 | 110 | ### Example Output 111 | 112 | ``` 113 | [2025-06-24T21:00:17+09:00] clients: 500 95per-rtt: 25ms min-rtt: 1ms median-rtt: 12ms max-rtt: 25ms 114 | [2025-06-24T21:00:19+09:00] clients: 1000 95per-rtt: 109ms min-rtt: 5ms median-rtt: 25ms max-rtt: 109ms 115 | [2025-06-24T21:00:22+09:00] clients: 1500 95per-rtt: 63ms min-rtt: 3ms median-rtt: 44ms max-rtt: 63ms 116 | [2025-06-24T21:00:25+09:00] clients: 2000 95per-rtt: 94ms min-rtt: 10ms median-rtt: 53ms max-rtt: 94ms 117 | ... 118 | [2025-06-24T21:00:58+09:00] clients: 5000 95per-rtt: 474ms min-rtt: 32ms median-rtt: 149ms max-rtt: 474ms 119 | ``` 120 | 121 | ## Visualizing Results 122 | 123 | You can generate JSON output files and visualize them using the included Ruby script: 124 | 125 | ```bash 126 | # Run benchmark with JSON output 127 | ./websocket-bench echo -f json -n dist/echo_$(date +%s).json ws://server-url 128 | 129 | # Generate HTML chart 130 | ruby etc/chart.rb dist chart.html 131 | ``` 132 | 133 | This will generate an HTML file with charts showing the median, 95th percentile, and maximum RTT for each benchmark. 134 | 135 | ## Pusher and Soketi Testing 136 | You can benchmark a Pusher Channels–compatible server [soketi](https://docs.soketi.app/) by running a local instance and pointing websocket‑bench at it 137 | 138 | ### 1. Run Soketi via Docker 139 | 140 | ```bash 141 | docker run \ 142 | --network host \ 143 | -e SOKETI_DEBUG=1 \ 144 | -p 6001:6001 \ 145 | -p 9601:9601 \ 146 | quay.io/soketi/soketi:1.4-16-debian 147 | ``` 148 | 149 | - WebSocket API will be listening on 127.0.0.1:6001 150 | - HTTP API (for triggers) will also be on http://127.0.0.1:6001 151 | 152 | It creates a single app with next accesses: 153 | ``` 154 | app_id=app-id 155 | key=app-key 156 | secret=app-secret 157 | ``` 158 | 159 | See the official [install guide](https://docs.soketi.app/getting-started/installation) 160 | 161 | ### Pusher connect url 162 | 163 | Pushed client url looks like that: 164 | 165 | ``` 166 | ws://ws-[cluster].pusher.com:[port]/app/[APP_KEY]?client=[library]&version=[lib_version]&protocol=[protocol_version] 167 | ``` 168 | 169 | - scheme: ws or wss 170 | - cluster: your cluster name (for soketi, this is omitted; use host directly) 171 | - port: soketi defaults to 6001 172 | - key: your Pusher App Key 173 | - client / version / protocol: library metadata 174 | 175 | **Soketi example:** 176 | ``` 177 | ws://127.0.0.1:6001/app/app-key?client=js&version=7.0.3&protocol=7 178 | ``` 179 | 180 | More details [here](https://pusher.com/docs/channels/library_auth_reference/pusher-websockets-protocol/) 181 | 182 | ### Running the Broadcast Pusher 183 | 184 | ``` 185 | ./websocket-bench broadcast "ws://127.0.0.1:6001/app/app-key?client=js&version=7.0.3&protocol=7" \ 186 | --server-type pusher \ 187 | --concurrent 5 \ 188 | --step-size 500 \ 189 | --steps-delay 1 \ 190 | --total-steps 10 191 | ``` 192 | 193 | If your `app-id`, `app-key`, or `app-secret` differ from the defaults above, pass them via `--pusher-*` flags so that the HMAC signature is computed correctly for private/presence channels 194 | 195 | 196 | ## Testing AnyCable 197 | 198 | websocket-bench supports multiple modes for working with AnyCable: 199 | 200 | - `anycable-pusher`: For testing broadcasting messages using the Pusher-compatible protocol 201 | - `anycable-connect`: For testing connection performance to AnyCable servers 202 | 203 | ### HTTP broadcast 204 | 205 | ```bash 206 | ./websocket-bench broadcast \ 207 | "ws://localhost:8080/app/app-key?client=bench&version=1.0&protocol=7" \ 208 | --server-type anycable-pusher \ 209 | --pusher-app-id app-id \ 210 | --pusher-app-key app-key \ 211 | --pusher-app-secret app-secret \ 212 | --anycable-backend http \ 213 | --concurrent 5 --step-size 500 --steps-delay 1 --total-steps 10 214 | ``` 215 | 216 | ### Redis backend 217 | 218 | ```bash 219 | ./websocket-bench broadcast \ 220 | "ws://localhost:8080/app/app-key?client=bench&version=1.0&protocol=7" \ 221 | --server-type anycable-pusher \ 222 | --pusher-app-id app-id \ 223 | --pusher-app-key app-key \ 224 | --pusher-app-secret app-secret \ 225 | --anycable-backend redis \ 226 | --anycable-redis-addr 127.0.0.1:6379 \ 227 | --concurrent 5 --step-size 500 --steps-delay 1 --total-steps 10 228 | ``` 229 | 230 | ### Connection performance testing 231 | 232 | ```bash 233 | ./websocket-bench connect \ 234 | "ws://localhost:8080/app/app-key?client=bench&version=1.0&protocol=7" \ 235 | --server-type anycable-connect \ 236 | --pusher-app-id app-id \ 237 | --pusher-app-key app-key \ 238 | --pusher-app-secret app-secret \ 239 | --concurrent 50 --step-size 500 --steps-delay 1 --total-steps 10 240 | ``` 241 | 242 | > Note: The `anycable-connect` server type works similarly to `pusher-connect` but is corrected for AnyCable - it skips the channel unsubscribe phase during connection closing, which is not needed for AnyCable 243 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/VividCortex/ewma v1.1.1 h1:MnEK4VOv6n0RSY4vtRe3h11qjxL3+t0B8yOL8iMXdcM= 2 | github.com/VividCortex/ewma v1.1.1/go.mod h1:2Tkkvm3sRDVXaiyucHiACn4cqf7DpdyLvmxzcbUokwA= 3 | github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 4 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 5 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 6 | github.com/cheggaaa/pb/v3 v3.0.1 h1:m0BngUk2LuSRYdx4fujDKNRXNDpbNCfptPfVT2m6OJY= 7 | github.com/cheggaaa/pb/v3 v3.0.1/go.mod h1:SqqeMF/pMOIu3xgGoxtPYhMNQP258xE4x/XRTYua+KU= 8 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 9 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 10 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 15 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 16 | github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= 17 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 18 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 19 | github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= 20 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 21 | github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= 22 | github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= 23 | github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 24 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 25 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 26 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 27 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 28 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 29 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 30 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 31 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 32 | github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= 33 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 34 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 35 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 36 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 37 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= 38 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 39 | github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 40 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 41 | github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 42 | github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= 43 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 44 | github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= 45 | github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 46 | github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= 47 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 48 | github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y= 49 | github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= 50 | github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= 51 | github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= 52 | github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= 53 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 54 | github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= 55 | github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= 56 | github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= 57 | github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= 58 | github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= 59 | github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 60 | github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= 61 | github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= 62 | github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= 63 | github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= 64 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 65 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 66 | github.com/pusher/pusher-http-go/v5 v5.1.1 h1:ZLUGdLA8yXMvByafIkS47nvuXOHrYmlh4bsQvuZnYVQ= 67 | github.com/pusher/pusher-http-go/v5 v5.1.1/go.mod h1:Ibji4SGoUDtOy7CVRhCiEpgy+n5Xv6hSL/QqYOhmWW8= 68 | github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8= 69 | github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= 70 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= 71 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 72 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 73 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 74 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 75 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 76 | github.com/vmihailenco/msgpack/v5 v5.3.2 h1:MsXyN2rqdM8NM0lLiIpTn610e8Zcoj8ZuHxsMOi9qhI= 77 | github.com/vmihailenco/msgpack/v5 v5.3.2/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= 78 | github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= 79 | github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= 80 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 81 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 82 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 83 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 84 | golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 h1:DZhuSZLsGlFL4CmhA8BcRA0mnthyA/nZ00AqCUo7vHg= 85 | golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 86 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 87 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 88 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 89 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 90 | golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 91 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 92 | golang.org/x/net v0.0.0-20210428140749-89ef3d95e781 h1:DzZ89McO9/gWPsQXS/FVKAlG02ZjaQ6AlZRBimEYOd0= 93 | golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= 94 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 95 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 96 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 97 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 98 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 99 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 100 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 101 | golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 102 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 103 | golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 104 | golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 105 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 106 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 107 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 108 | golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 109 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 110 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM= 111 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 112 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 113 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 114 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 115 | golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= 116 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 117 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 118 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 119 | golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 120 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 121 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 122 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 123 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 124 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 125 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 126 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 127 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 128 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 129 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 130 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 131 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 132 | google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk= 133 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 134 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 135 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 136 | gopkg.in/stretchr/testify.v1 v1.2.2 h1:yhQC6Uy5CqibAIlk1wlusa/MJ3iAN49/BsR/dCCKz3M= 137 | gopkg.in/stretchr/testify.v1 v1.2.2/go.mod h1:QI5V/q6UbPmuhtm10CaFZxED9NreB8PnFYN9JcR6TxU= 138 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 139 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 140 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 141 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 142 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 143 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 144 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 145 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 146 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 147 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net" 9 | "os" 10 | "path/filepath" 11 | "strconv" 12 | "time" 13 | 14 | "github.com/anycable/websocket-bench/benchmark" 15 | "golang.org/x/net/websocket" 16 | 17 | "github.com/spf13/cobra" 18 | ) 19 | 20 | var options struct { 21 | websocketOrigin string 22 | websocketProtocol string 23 | serverType string 24 | concurrent int 25 | concurrentConnect int 26 | sampleSize int 27 | initialClients int 28 | stepSize int 29 | limitPercentile int 30 | limitRTT time.Duration 31 | payloadPaddingSize int 32 | localAddrs []string 33 | workerListenAddr string 34 | workerListenPort int 35 | workerAddrs []string 36 | totalSteps int 37 | interactive bool 38 | stepsDelay int 39 | commandDelay float64 40 | commandDelayChance int 41 | broadastsWait int 42 | channel string 43 | actionCableEncoding string 44 | format string 45 | filename string 46 | // Pusher config 47 | pusherAppID string 48 | pusherAppKey string 49 | pusherAppSecret string 50 | pusherChannel string 51 | pusherBroadcastChannel string 52 | pusherHost string 53 | pusherPort string 54 | 55 | // AnyCable Pusher specific config 56 | anycableRedisAddr string 57 | anycableHTTPEndpoint string 58 | anycableBackend string 59 | } 60 | 61 | var ( 62 | version string 63 | commit string 64 | ) 65 | 66 | func init() { 67 | if version == "" { 68 | version = "0.3.0" 69 | } 70 | 71 | if commit != "" { 72 | version = version + "-" + commit 73 | } 74 | } 75 | 76 | func main() { 77 | rootCmd := &cobra.Command{Use: "websocket-bench", Short: fmt.Sprintf("websocket benchmark tool (%s)", version)} 78 | 79 | cmdEcho := &cobra.Command{ 80 | Use: "echo URL", 81 | Short: "Echo stress test", 82 | Long: "Stress test 1 to 1 performance with an echo test", 83 | Run: Stress, 84 | } 85 | cmdEcho.PersistentFlags().StringVarP(&options.websocketOrigin, "origin", "o", "http://localhost", "websocket origin") 86 | cmdEcho.PersistentFlags().StringSliceVarP(&options.localAddrs, "local-addr", "l", []string{}, "local IP address to connect from") 87 | cmdEcho.PersistentFlags().StringVarP(&options.serverType, "server-type", "", "json", "server type to connect to (json, binary, actioncable, phoenix)") 88 | cmdEcho.PersistentFlags().StringSliceVarP(&options.workerAddrs, "worker-addr", "w", []string{}, "worker address to distribute connections to") 89 | cmdEcho.PersistentFlags().StringVarP(&options.websocketProtocol, "sub-protocol", "", "", "WS sub-protocol to use") 90 | cmdEcho.Flags().IntVarP(&options.concurrent, "concurrent", "c", 50, "concurrent echo requests") 91 | cmdEcho.Flags().IntVarP(&options.sampleSize, "sample-size", "s", 10000, "number of echoes in a sample") 92 | cmdEcho.Flags().IntVarP(&options.stepSize, "step-size", "", 5000, "number of clients to increase each step") 93 | cmdEcho.Flags().IntVarP(&options.limitPercentile, "limit-percentile", "", 95, "round-trip time percentile to for limit") 94 | cmdEcho.Flags().IntVarP(&options.payloadPaddingSize, "payload-padding", "", 0, "payload padding size") 95 | cmdEcho.Flags().DurationVarP(&options.limitRTT, "limit-rtt", "", time.Millisecond*500, "Max RTT at limit percentile") 96 | cmdEcho.Flags().IntVarP(&options.totalSteps, "total-steps", "", 0, "Run benchmark for specified number of steps") 97 | cmdEcho.Flags().BoolVarP(&options.interactive, "interactive", "i", false, "Interactive mode (requires user input to move to the next step") 98 | cmdEcho.Flags().IntVarP(&options.stepsDelay, "steps-delay", "", 0, "Sleep for seconds between steps") 99 | cmdEcho.Flags().Float64VarP(&options.commandDelay, "command-delay", "", 0, "Sleep for seconds before sending client command") 100 | cmdEcho.Flags().IntVarP(&options.commandDelayChance, "command-delay-chance", "", 100, "The percentage of commands to add delay to") 101 | cmdEcho.Flags().StringVarP(&options.format, "format", "f", "", "output format") 102 | cmdEcho.Flags().StringVarP(&options.filename, "filename", "n", "", "output filename") 103 | cmdEcho.Flags().StringVarP(&options.actionCableEncoding, "action-cable-encoding", "", "json", "Action Cable messages encoding (json, msgpack, protobuf)") 104 | cmdEcho.PersistentFlags().StringVarP(&options.channel, "channel", "", "{\"channel\":\"BenchmarkChannel\"}", "Action Cable channel identifier") 105 | rootCmd.AddCommand(cmdEcho) 106 | 107 | cmdBroadcast := &cobra.Command{ 108 | Use: "broadcast URL", 109 | Short: "Broadcast stress test", 110 | Long: "Stress test 1 to many performance with an broadcast test", 111 | Run: Stress, 112 | } 113 | cmdBroadcast.PersistentFlags().StringVarP(&options.websocketOrigin, "origin", "o", "http://localhost", "websocket origin") 114 | cmdBroadcast.PersistentFlags().StringSliceVarP(&options.localAddrs, "local-addr", "l", []string{}, "local IP address to connect from") 115 | cmdBroadcast.PersistentFlags().StringSliceVarP(&options.workerAddrs, "worker-addr", "w", []string{}, "worker address to distribute connections to") 116 | cmdBroadcast.PersistentFlags().StringVarP(&options.serverType, "server-type", "", "json", "server type to connect to (json, binary, actioncable, phoenix)") 117 | cmdBroadcast.PersistentFlags().StringVarP(&options.websocketProtocol, "sub-protocol", "", "", "WS sub-protocol to use") 118 | cmdBroadcast.Flags().IntVarP(&options.concurrent, "concurrent", "c", 4, "concurrent broadcast requests") 119 | cmdBroadcast.Flags().IntVarP(&options.concurrentConnect, "connect-concurrent", "", 100, "concurrent connection initialization requests") 120 | cmdBroadcast.Flags().IntVarP(&options.sampleSize, "sample-size", "s", 20, "number of broadcasts in a sample") 121 | cmdBroadcast.Flags().IntVarP(&options.initialClients, "initial-clients", "", 0, "initial number of clients") 122 | cmdBroadcast.Flags().IntVarP(&options.stepSize, "step-size", "", 5000, "number of clients to increase each step") 123 | cmdBroadcast.Flags().IntVarP(&options.limitPercentile, "limit-percentile", "", 95, "round-trip time percentile to for limit") 124 | cmdBroadcast.Flags().IntVarP(&options.payloadPaddingSize, "payload-padding", "", 0, "payload padding size") 125 | cmdBroadcast.Flags().DurationVarP(&options.limitRTT, "limit-rtt", "", time.Millisecond*500, "Max RTT at limit percentile") 126 | cmdBroadcast.Flags().IntVarP(&options.totalSteps, "total-steps", "", 0, "Run benchmark for specified number of steps") 127 | cmdBroadcast.Flags().BoolVarP(&options.interactive, "interactive", "i", false, "Interactive mode (requires user input to move to the next step") 128 | cmdBroadcast.Flags().IntVarP(&options.stepsDelay, "steps-delay", "", 0, "Sleep for seconds between steps") 129 | cmdBroadcast.Flags().Float64VarP(&options.commandDelay, "command-delay", "", 0, "Sleep for seconds before sending client command") 130 | cmdBroadcast.Flags().IntVarP(&options.commandDelayChance, "command-delay-chance", "", 100, "The percentage of commands to add delay to") 131 | cmdBroadcast.Flags().IntVarP(&options.broadastsWait, "wait-broadcasts", "", 2, "Sleep for seconds after the last step made to collect the broadcasts") 132 | cmdBroadcast.Flags().StringVarP(&options.format, "format", "f", "", "output format") 133 | cmdBroadcast.Flags().StringVarP(&options.filename, "filename", "n", "", "output filename") 134 | cmdBroadcast.Flags().StringVarP(&options.actionCableEncoding, "action-cable-encoding", "", "json", "Action Cable messages encoding (json, msgpack, protobuf)") 135 | cmdBroadcast.Flags().StringVarP(&options.pusherAppID, "pusher-app-id", "", "app-id", "Pusher App ID for connect/broadcast tests") 136 | cmdBroadcast.Flags().StringVarP(&options.pusherAppKey, "pusher-app-key", "", "app-key", "Pusher App Key for connect/broadcast tests") 137 | cmdBroadcast.Flags().StringVarP(&options.pusherAppSecret, "pusher-app-secret", "", "app-secret", "Pusher App Secret for connect/broadcast tests") 138 | cmdBroadcast.Flags().StringVar(&options.pusherBroadcastChannel, "pusher-channel", "private-benchmark", "Pusher channel name for connect/broadcast tests") 139 | cmdBroadcast.Flags().StringVar(&options.pusherHost, "pusher-host", "127.0.0.1", "Pusher host for connect/broadcast tests") 140 | cmdBroadcast.Flags().StringVar(&options.pusherPort, "pusher-port", "6001", "Pusher port for connect/broadcast tests") 141 | cmdBroadcast.Flags().StringVar(&options.anycableBackend, "anycable-backend", "http", "Broadcast backend for AnyCable (redis|http)") 142 | cmdBroadcast.Flags().StringVar(&options.anycableHTTPEndpoint, "anycable-http-endpoint", "http://localhost:8080/_broadcast", "HTTP /_broadcast endpoint for AnyCable") 143 | cmdBroadcast.Flags().StringVar(&options.anycableRedisAddr, "anycable-redis-addr", "127.0.0.1:6379", "Redis address for AnyCable broadcasts") 144 | 145 | cmdBroadcast.PersistentFlags().StringVarP(&options.channel, "channel", "", "{\"channel\":\"BenchmarkChannel\"}", "Action Cable channel identifier") 146 | rootCmd.AddCommand(cmdBroadcast) 147 | 148 | cmdWorker := &cobra.Command{ 149 | Use: "worker", 150 | Short: "Run in worker mode", 151 | Long: "Listen for commands", 152 | Run: Work, 153 | } 154 | cmdWorker.Flags().StringVarP(&options.workerListenAddr, "address", "a", "0.0.0.0", "address to listen on") 155 | cmdWorker.Flags().IntVarP(&options.workerListenPort, "port", "p", 3000, "port to listen on") 156 | rootCmd.AddCommand(cmdWorker) 157 | 158 | cmdConnect := &cobra.Command{ 159 | Use: "connect URL", 160 | Short: "Connection initialization stress test", 161 | Long: "Stress test connection initialzation", 162 | Run: Stress, 163 | } 164 | cmdConnect.PersistentFlags().StringVarP(&options.websocketOrigin, "origin", "o", "http://localhost", "websocket origin") 165 | cmdConnect.PersistentFlags().StringSliceVarP(&options.localAddrs, "local-addr", "l", []string{}, "local IP address to connect from") 166 | cmdConnect.PersistentFlags().StringVarP(&options.serverType, "server-type", "", "json", "server type to connect to (json, binary, actioncable, phoenix)") 167 | cmdConnect.PersistentFlags().StringSliceVarP(&options.workerAddrs, "worker-addr", "w", []string{}, "worker address to distribute connections to") 168 | cmdConnect.Flags().IntVarP(&options.concurrent, "concurrent", "c", 50, "concurrent connection requests") 169 | cmdConnect.Flags().IntVarP(&options.stepSize, "step-size", "", 5000, "number of clients to connect at each step") 170 | cmdConnect.Flags().IntVarP(&options.totalSteps, "total-steps", "", 0, "Run benchmark for specified number of steps") 171 | cmdConnect.Flags().BoolVarP(&options.interactive, "interactive", "i", false, "Interactive mode (requires user input to move to the next step") 172 | cmdConnect.Flags().IntVarP(&options.stepsDelay, "steps-delay", "", 0, "Sleep for seconds between steps") 173 | cmdConnect.Flags().Float64VarP(&options.commandDelay, "command-delay", "", 0, "Sleep for seconds before sending client command") 174 | cmdConnect.Flags().IntVarP(&options.commandDelayChance, "command-delay-chance", "", 100, "The percentage of commands to add delay to") 175 | cmdConnect.Flags().StringVarP(&options.format, "format", "f", "", "output format") 176 | cmdConnect.Flags().StringVarP(&options.filename, "filename", "n", "", "output filename") 177 | cmdConnect.Flags().StringVarP(&options.actionCableEncoding, "action-cable-encoding", "", "json", "Action Cable messages encoding (json, msgpack, protobuf)") 178 | cmdConnect.Flags().StringVar(&options.pusherChannel, "pusher-channel", "Benchmark", "Pusher channel name for connect tests") 179 | cmdConnect.PersistentFlags().StringVarP(&options.channel, "channel", "", "{\"channel\":\"BenchmarkChannel\"}", "Action Cable channel identifier") 180 | rootCmd.AddCommand(cmdConnect) 181 | 182 | rootCmd.Execute() 183 | } 184 | 185 | func Stress(cmd *cobra.Command, args []string) { 186 | if len(args) != 1 { 187 | cmd.Help() 188 | os.Exit(1) 189 | } 190 | 191 | config := &benchmark.Config{} 192 | config.WebsocketURL = args[0] 193 | config.WebsocketOrigin = options.websocketOrigin 194 | config.ServerType = options.serverType 195 | switch cmd.Name() { 196 | case "echo": 197 | config.ClientCmd = benchmark.ClientEchoCmd 198 | case "broadcast": 199 | config.ClientCmd = benchmark.ClientBroadcastCmd 200 | case "connect": 201 | default: 202 | panic("invalid command name") 203 | } 204 | config.PayloadPaddingSize = options.payloadPaddingSize 205 | config.StepSize = options.stepSize 206 | config.Concurrent = options.concurrent 207 | config.ConcurrentConnect = options.concurrentConnect 208 | config.SampleSize = options.sampleSize 209 | config.InitialClients = options.initialClients 210 | config.LimitPercentile = options.limitPercentile 211 | config.LimitRTT = options.limitRTT 212 | config.TotalSteps = options.totalSteps 213 | config.Interactive = options.interactive 214 | config.StepDelay = time.Duration(options.stepsDelay) * time.Second 215 | config.CommandDelay = time.Duration(options.commandDelay) * time.Second 216 | config.CommandDelayChance = options.commandDelayChance 217 | config.WaitBroadcastsSeconds = options.broadastsWait 218 | 219 | var writer io.Writer 220 | if options.filename == "" { 221 | writer = os.Stdout 222 | } else { 223 | var cancel context.CancelFunc 224 | writer, cancel = openFileWriter() 225 | defer cancel() 226 | } 227 | 228 | if options.format == "json" { 229 | config.ResultRecorder = benchmark.NewJSONResultRecorder(writer) 230 | } else { 231 | config.ResultRecorder = benchmark.NewTextResultRecorder(writer) 232 | } 233 | 234 | benchmark.CableConfig.Channel = options.channel 235 | benchmark.CableConfig.Encoding = options.actionCableEncoding 236 | 237 | fillCommon(&benchmark.PusherConfig.PusherCommonConfig) 238 | fillCommon(&benchmark.AnyCablePusherConfig.PusherCommonConfig) 239 | 240 | benchmark.PusherConnectConfig.Channel = options.pusherChannel 241 | benchmark.PusherConfig.Host = options.pusherHost 242 | benchmark.PusherConfig.Port = options.pusherPort 243 | 244 | benchmark.AnyCablePusherConfig.Backend = options.anycableBackend 245 | benchmark.AnyCablePusherConfig.HTTPAddr = options.anycableHTTPEndpoint 246 | benchmark.AnyCablePusherConfig.RedisAddr = options.anycableRedisAddr 247 | 248 | wsconfig, err := websocket.NewConfig(config.WebsocketURL, config.WebsocketOrigin) 249 | if err != nil { 250 | panic(fmt.Errorf("failed to generate WS config: %v", err)) 251 | } 252 | 253 | if options.websocketProtocol != "" { 254 | wsconfig.Protocol = []string{options.websocketProtocol} 255 | } 256 | 257 | benchmark.RemoteAddr.Config = wsconfig 258 | benchmark.RemoteAddr.Secure = wsconfig.Location.Scheme == "wss" 259 | 260 | if raddr, host, err := parseRemoteAddr(wsconfig.Location.Host); err != nil { 261 | panic(fmt.Errorf("failed to parse remote address: %v", err)) 262 | } else { 263 | benchmark.RemoteAddr.Addr = raddr 264 | benchmark.RemoteAddr.Host = host 265 | } 266 | 267 | localAddrs := parseTCPAddrs(options.localAddrs) 268 | for _, a := range localAddrs { 269 | config.ClientPools = append(config.ClientPools, benchmark.NewLocalClientPool(a)) 270 | } 271 | 272 | for _, a := range options.workerAddrs { 273 | rcp, err := benchmark.NewRemoteClientPool(a) 274 | if err != nil { 275 | log.Fatal(err) 276 | } 277 | config.ClientPools = append(config.ClientPools, rcp) 278 | } 279 | 280 | if cmd.Name() == "connect" { 281 | b := benchmark.NewConnect(config) 282 | err := b.Run() 283 | if err != nil { 284 | log.Fatal(err) 285 | } 286 | } else { 287 | b := benchmark.New(config) 288 | err := b.Run() 289 | if err != nil { 290 | log.Fatal(err) 291 | } 292 | } 293 | if err := config.ResultRecorder.Flush(); err != nil { 294 | log.Fatal(err) 295 | } 296 | } 297 | 298 | func Work(cmd *cobra.Command, args []string) { 299 | worker := benchmark.NewWorker(options.workerListenAddr, uint16(options.workerListenPort)) 300 | err := worker.Serve() 301 | if err != nil { 302 | log.Fatal(err) 303 | } 304 | } 305 | 306 | func parseTCPAddrs(stringAddrs []string) []*net.TCPAddr { 307 | var tcpAddrs []*net.TCPAddr 308 | for _, s := range stringAddrs { 309 | tcpAddrs = append(tcpAddrs, &net.TCPAddr{IP: net.ParseIP(s)}) 310 | } 311 | 312 | if len(tcpAddrs) == 0 { 313 | tcpAddrs = []*net.TCPAddr{nil} 314 | } 315 | 316 | return tcpAddrs 317 | } 318 | 319 | func parseRemoteAddr(url string) (*net.TCPAddr, string, error) { 320 | host, port, err := net.SplitHostPort(url) 321 | if err != nil { 322 | return nil, "", err 323 | } 324 | 325 | destIPs, err := net.LookupHost(host) 326 | if err != nil { 327 | return nil, "", err 328 | } 329 | 330 | nport, err := strconv.ParseUint(port, 10, 16) 331 | if err != nil { 332 | return nil, "", err 333 | } 334 | 335 | ip := net.ParseIP(destIPs[0]) 336 | if host == "localhost" { 337 | ip = nil 338 | } 339 | 340 | return &net.TCPAddr{IP: ip, Port: int(nport)}, host, nil 341 | } 342 | 343 | func openFileWriter() (io.Writer, context.CancelFunc) { 344 | var err error 345 | dir := filepath.Dir(options.filename) 346 | if _, err := os.Stat(dir); err != nil { 347 | if err := os.MkdirAll(dir, os.ModePerm); err != nil { 348 | panic(fmt.Errorf("failed to create output dir: %v", err)) 349 | } 350 | } 351 | file, err := os.OpenFile(options.filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.ModePerm) 352 | if err != nil { 353 | panic(fmt.Errorf("failed to open output file: %v", err)) 354 | } 355 | return file, func() { _ = file.Close() } 356 | } 357 | 358 | func fillCommon(pcc *benchmark.PusherCommonConfig) { 359 | pcc.Channel = options.pusherBroadcastChannel 360 | pcc.AppKey = options.pusherAppKey 361 | pcc.AppID = options.pusherAppID 362 | pcc.AppSecret = options.pusherAppSecret 363 | } 364 | --------------------------------------------------------------------------------