Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/ci-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ on:
- main

env:
GO_VERSION: 1.26.4
GO_VERSION: 1.26.5
GOLANGCI_LINT_VERSION: v2.12.2

jobs:
golangci:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
Expand All @@ -31,7 +31,7 @@ jobs:
name: Unit tests (race)
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
Expand All @@ -42,7 +42,7 @@ jobs:
name: Unit tests (flaky)
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
Expand All @@ -53,7 +53,7 @@ jobs:
name: Vulnerability scan
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
Expand Down
20 changes: 10 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ permissions:
contents: write

env:
GO_VERSION: 1.26.4
GO_VERSION: 1.26.5
GOLANGCI_LINT_VERSION: v2.12.2

jobs:
Expand All @@ -24,7 +24,7 @@ jobs:
outputs:
version: ${{ steps.get.outputs.version }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- id: get
run: |
raw=$(cat VERSION)
Expand All @@ -35,7 +35,7 @@ jobs:
needs: [initialize]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0

Expand Down Expand Up @@ -87,15 +87,15 @@ jobs:
needs: [initialize]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- run: bash scripts/check-go-version.sh

test-race:
name: Test race
runs-on: ubuntu-24.04
needs: [version-bump-check, go-version-align]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0

Expand All @@ -112,7 +112,7 @@ jobs:
runs-on: ubuntu-24.04
needs: [version-bump-check, go-version-align]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0

Expand All @@ -128,7 +128,7 @@ jobs:
runs-on: ubuntu-24.04
needs: [version-bump-check, go-version-align]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0

Expand All @@ -147,7 +147,7 @@ jobs:
runs-on: ubuntu-24.04
needs: [version-bump-check, go-version-align]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0

Expand All @@ -167,7 +167,7 @@ jobs:
version: ${{ steps.compute_tag.outputs.version }}
is_prerelease: ${{ steps.compute_tag.outputs.is_prerelease }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0

Expand Down Expand Up @@ -377,7 +377,7 @@ jobs:
needs: [release]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0 # snapcraft.yaml's `git describe --tags` needs full history

Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `wsstat measure --version` and `wsstat stream --version` now print the version instead of failing with an unknown-flag error.

### Fixed

- (lib) Mixed subscription + measurement sessions no longer report a zeroed `MessageRTT`: `Subscribe`'s initial payload write is no longer recorded in the RTT write ledger (subscription responses were never recorded as reads, so the unbalanced ledgers discarded all RTT data). A write dropped during connection teardown likewise no longer leaves an orphan write timing.
- (lib) `DialContext` now rejects reuse: dialing a closed instance returns `ErrClosed`, and a second dial on a live instance returns an error instead of silently leaking the previous connection and its pumps. A failed dial leaves the instance reusable.
- (lib) Request/response read timings are now stamped when the frame arrives in the read pump rather than when the consumer drains the channel, so time spent buffered no longer inflates `MessageRTT` (matching how subscription deliveries were already stamped).
- Subcommand help (`wsstat measure -h`, `wsstat stream -h`) now prints to stdout, matching top-level `-h` and GNU convention, so it can be piped to a pager. Parse errors keep printing usage to stderr.
- Misordered invocations now get targeted hints: flags after the URL (`wsstat <url> -v`) explain that flags must come before the URL, and a subcommand after global flags (`wsstat -v stream <url>`) explains that the subcommand must come first. Previously both failed with a bare "expected exactly one URL argument".
- `-b/--buffer` help no longer claims `[default: 0]`; the effective default queue length is 32.

## [3.1.0] - 2026-06-24

### Added
Expand Down
54 changes: 37 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,34 @@ This project provides a way to stat a WebSocket connection; measure the latency
The CLI client provides a simple and easy to use tool to check the status of a WebSocket endpoint:

```sh
~ wsstat example.org
~ wsstat -v echo.websocket.org

Target: example.org
IP: 1.2.3.4
Target: echo.websocket.org
IP: 66.241.124.119
Messages sent: 1
WS version: 13
TLS version: TLS 1.3

DNS Lookup TCP Connection TLS Handshake WS Handshake Message RTT
| 61ms | 22ms | 44ms | 29ms | 27ms |
| 1.541ms | 287.437ms | 321.186ms | 730.487ms | 518.387ms |
| | | | | |
| DNS lookup:61ms | | | |
| TCP connected:84ms | | |
| TLS done:128ms | |
| WS done:158ms |
- Total:186ms
| DNS lookup:1.541ms | | | |
| TCP connected:288.979ms | | |
| TLS done:610.165ms | |
| WS done:1340.652ms |
- Total:2366.209ms
```

Without `-v`, output is a compact summary:

```sh
~ wsstat echo.websocket.org

URL: echo.websocket.org
IP: 66.241.124.119

Round-trip time: 492.884ms (1 message)
Total time: 2296.079ms
```

The client replicates what [reorx/httpstat](https://github.com/reorx/httpstat) and [davecheney/httpstat](https://github.com/davecheney/httpstat) does for HTTP, but for WebSocket. It is said that imitation is the sincerest form of flattery, and inspiration has for certain been sourced from these projects.
Expand All @@ -51,17 +64,15 @@ Install via Go:

```sh
# To install the latest version, specify other releases with @<tag>
go install github.com/jkbrsn/wsstat/v3@latest
go install github.com/jkbrsn/wsstat/v3/cmd/wsstat@latest

# To include the version in the binary, run the install from the root of the repo
git clone github.com/jkbrsn/wsstat
# To include the version in the binary, build from a clone of the repo
git clone https://github.com/jkbrsn/wsstat.git
cd wsstat
git fetch --all
git checkout origin/main
go install -ldflags "-X main.version=$(cat VERSION)" ./cmd/wsstat
```

Note: installing the package with `@latest` will always install the latest version no matter the other parameters of the command.
Note: a remote `go install ...@<tag>` cannot inject the version via ldflags, so a binary installed that way reports its version as `unknown`. Build from a clone (as above) to get the version stamped.

The snap is listed here: [snapcraft.io/wsstat](https://snapcraft.io/wsstat)

Expand Down Expand Up @@ -136,6 +147,9 @@ wsstat --rpc-method eth_blockNumber wss://rpc.example.com/ws
# Start a stream
wsstat stream --summary-interval 5s wss://stream.example.com/feed

# Record response payloads to a file as NDJSON (in addition to normal output)
wsstat stream --file capture.ndjson wss://stream.example.com/feed

# Attach headers to dial request
wsstat -H "Authorization: Bearer TOKEN" -H "Origin: https://foo" wss://api.example.com/ws

Expand Down Expand Up @@ -191,8 +205,9 @@ wsstat stream --once -t '{"method":"subscribe_ticker"}' wss://example.org/ws
Output is split across three orthogonal axes:

- `-o, --output text|json|raw` — the whole-stdout contract. `json` emits
newline-delimited envelopes with a stable schema (`-v`/`-vv` never change which
fields appear); `raw` writes payload bytes verbatim with nothing added — no
newline-delimited envelopes with a stable, published schema (see
[docs/schema/](./docs/schema/); `-v`/`-vv` never change which fields appear);
`raw` writes payload bytes verbatim with nothing added — no
label, color, or trailing newline, so frames stay binary-safe and stream frames
concatenate undelimited (use `-o json` when you need a delimiter). In measure
mode `raw` requires `--text` or `--rpc-method`; with `--rpc-method` the response
Expand All @@ -203,6 +218,11 @@ Output is split across three orthogonal axes:
- `--clip` — clips each rendered line to the terminal width with a trailing `...`
(text output, TTY only; a no-op when piped or redirected).

Orthogonal to all three axes, `--file <path>` additionally records each response
payload to `<path>` as NDJSON, one per line. It captures response bodies only
(summaries and other chrome still go to stdout), refuses to overwrite an
existing file, and removes the file again if nothing was recorded.

```sh
# Machine-readable streaming summaries and events
wsstat stream -o json --summary-interval 5s wss://example.org/stream
Expand Down
29 changes: 26 additions & 3 deletions cmd/wsstat/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ type commonFlags struct {
subprotocol string
validateUTF8 bool

debug bool
debug bool
version bool
}

// registerCommon registers the shared flags onto fs, binding them to c.
Expand Down Expand Up @@ -96,6 +97,7 @@ func registerCommon(fs *flag.FlagSet, c *commonFlags) {
"validate UTF-8 on inbound text frames and warn on violations (coder/websocket skips this)")
fs.BoolVar(&c.debug, "debug", false,
"emit core debug logs to stderr (independent of -v/-vv output verbosity)")
fs.BoolVar(&c.version, "version", false, "print program version and exit")
}

// textOnlyFlags maps the internal flag names rejected under json/raw output to
Expand Down Expand Up @@ -130,7 +132,7 @@ func resolveCommon(fs *flag.FlagSet, c *commonFlags, mode app.Mode) ([]app.Optio
switch color {
case "auto", "always", "never":
default:
return nil, nil, errors.New("-color must be auto, always, or never")
return nil, nil, errors.New("--color must be auto, always, or never")
}

verbosity := 0
Expand Down Expand Up @@ -334,7 +336,7 @@ func setFlagNames(fs *flag.FlagSet) map[string]bool {
func positionalURL(fs *flag.FlagSet) (*url.URL, error) {
rest := fs.Args()
if len(rest) != 1 {
return nil, errors.New("expected exactly one URL argument")
return nil, positionalErr(rest)
}
target, err := parseWSURI(rest[0])
if err != nil {
Expand All @@ -343,6 +345,27 @@ func positionalURL(fs *flag.FlagSet) (*url.URL, error) {
return target, nil
}

// positionalErr diagnoses the common orderings behind a wrong positional count:
// a subcommand placed after global flags (dispatch keys on args[0] only), and
// flags placed after the URL (stdlib flag stops parsing at the first positional).
func positionalErr(rest []string) error {
base := errors.New("expected exactly one URL argument")
if len(rest) < 2 {
return base
}
if rest[0] == "measure" || rest[0] == "stream" {
return fmt.Errorf("%w: the subcommand must come first: wsstat %s [options] <url>",
base, rest[0])
}
for _, arg := range rest[1:] {
if strings.HasPrefix(arg, "-") {
return fmt.Errorf("%w: flags must come before the URL (found %q after %q)",
base, arg, rest[0])
}
}
return base
}

// parseWSURI parses rawURI into a URL, defaulting a missing scheme to wss://. Only the
// ws and wss schemes are accepted; http/https (and anything else) are rejected so they are
// not silently dialed as plaintext by the lenient underlying dialer.
Expand Down
9 changes: 9 additions & 0 deletions cmd/wsstat/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,15 @@ func TestMeasureFlags(t *testing.T) {
_, _, err := buildMeasure([]string{"-v", "stream", "example.com"})
require.Error(t, err)
assert.Contains(t, err.Error(), "exactly one URL")
assert.Contains(t, err.Error(), "the subcommand must come first")
})

t.Run("flags after the URL get an ordering hint", func(t *testing.T) {
// stdlib flag stops parsing at the first positional, so -v becomes a positional.
_, _, err := buildMeasure([]string{"example.com", "-v"})
require.Error(t, err)
assert.Contains(t, err.Error(), "exactly one URL")
assert.Contains(t, err.Error(), "flags must come before the URL")
})

t.Run("headers accumulate", func(t *testing.T) {
Expand Down
Loading