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
27 changes: 27 additions & 0 deletions .claude/skills/verify-wsstat/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ make build # -> ./bin/wsstat
./dev/run.sh up # Dockerized mock on ws://localhost:17080 and wss://localhost:17443
```

`run.sh up` runs in the **foreground** and blocks until Ctrl+C, which fires its
teardown trap (`docker compose down`). To drive the binary from the same session
you must background it — and then **you** own the teardown (see below):

```bash
./dev/run.sh up & # background so you can drive; you must tear it down when done
until curl -fsS http://localhost:17080/healthz >/dev/null 2>&1; do sleep 0.5; done
```

No Docker? Run the mock on the host instead:

```bash
Expand All @@ -34,6 +43,24 @@ Pick the endpoint that isolates the changed feature (`/echo`, `/jsonrpc`,
./bin/wsstat stream -c 2 -o json -t '{"method":"subscribe","subscription":{}}' ws://localhost:17080/subscriptions
```

## Tear down

Always stop the stack you started — a backgrounded `run.sh up` keeps blocking and
holds ports 17080/17443 otherwise. Ctrl+C (SIGINT) is the clean teardown: it fires
run.sh's trap, which runs `docker compose down`. From a non-interactive session,
send it that signal — do **not** just `docker compose down`, which leaves the
`run.sh` process orphaned:

```bash
pkill -INT -f 'dev/run.sh up' # Ctrl+C the mock; its trap tears the stack down
# host-run mock instead? kill by port -- `go run .` compiles to /tmp/go-build…/exe/mock-server,
# so a pkill on 'dev/mock-server' misses it:
fuser -k 17080/tcp 17443/tcp
```

Or skip the manual lifecycle entirely: `make smoke` / `make soak` build, run, and
tear the stack down in one shot — prefer these unless you need ad-hoc drive commands.

## Make it stick

A one-off invocation proves the change today; the suites keep proving it:
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ go test ./ -run 'TestWSStat/Basic' -v # focused subtest
Module `github.com/jkbrsn/wsstat/v3`. Three layers:

1. **CLI** (`cmd/wsstat/`) - parses flags, validates URLs (auto-adds `wss://`), builds config, delegates to the app layer.
2. **App** (`internal/app/`) - `Client` orchestrates measurement/subscription flows and output formatting.
2. **App** (`internal/app/`) - `Client` orchestrates measurement/subscription/ping flows and output formatting.
3. **Core** (root pkg: `wsstat.go`, `measure.go`, `result.go`, `subscription.go`) - public API; wraps coder/websocket with timing instrumentation. `WSStat` produces a `Result` with DNS/TCP/TLS/WS/RTT timings; the one-shot `Measure*` free functions live in `measure.go`.

All layers use the functional options pattern: `New(opts ...Option)` with `WithTimeout()`, `WithTLSConfig()`, `WithBufferSize()`, etc.
Expand Down
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Repeatable `-t` in `stream` mode.** `wsstat stream` now accepts `-t/--text` multiple times and sends each message in argv order on the same connection, enabling multi-frame conversations (e.g. subscribe then unsubscribe) against a single server-side session. Sends are spaced by the new `--send-delay <duration>` flag (default `1s`) so responses interleave predictably under `-o json`. The first message remains the subscribe payload; receiving (`-c`, `--once`, `--timeout`) is unchanged and a single `-t` behaves exactly as before; if the receive limit is reached before all messages are sent, the remaining sends are skipped. `measure` mode rejects repeated `-t`.
- **`ping` subcommand.** `wsstat ping <url>` dials once and sends a WebSocket ping frame every `-i/--interval`, printing a per-ping RTT line and a `ping(8)`-style `STATS` summary; a missed pong is a survivable `timeout` and the run continues, exit 1 covers total loss and dial failure. See the README's "Ping Mode" section and `wsstat ping -h` for the full flag and output contract.
- **(lib) `WithUnboundedReads` option.** Drops the read pump's per-read timeout so long-lived sessions carried only by control frames (e.g. a ping/pong monitor) are not torn down as idle.
- **(lib) `WithDiscardReads` option.** Makes the read pump drop data frames not claimed by a subscription instead of queueing them, so a session that never reads keeps processing pongs against a chatty peer.
- **(lib) `PingPongContext` method.** `PingPong` additionally bounded by a caller context, so cancellation interrupts a ping blocked on an unresponsive peer instead of waiting out the full read timeout.
- **Repeatable `-t` in `stream` mode.** `wsstat stream` now accepts `-t/--text` multiple times, sending each message in argv order on the same connection, spaced by the new `--send-delay` flag (default `1s`); a single `-t` behaves as before and `measure` rejects repeats. See the README's "Stream Mode" section for details.

## [3.1.1] - 2026-07-13

Expand Down
42 changes: 40 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,9 @@ For a full list of the available options, run `wsstat -h`, `wsstat measure -h`,
### Modes

`wsstat <url>` (or `wsstat measure <url>`) measures connection latency. `wsstat
stream <url>` keeps the socket open for long-lived feeds. A host literally named
`measure`/`stream` must be spelled with a scheme (`wsstat wss://stream`).
stream <url>` keeps the socket open for long-lived feeds. `wsstat ping <url>`
watches ping/pong latency over time. A host literally named
`measure`/`stream`/`ping` must be spelled with a scheme (`wsstat wss://ping`).

In measure mode, `-c N` aggregates timing across all interactions; the response
printed is the first one (measure does not concatenate responses).
Expand Down Expand Up @@ -214,6 +215,39 @@ wsstat stream -c 4 -o json \
wss://example.org/ws
```

### Ping Mode

`wsstat ping <url>` dials once and sends a WebSocket ping frame every
`-i/--interval` (default `1s`) on that connection, printing a per-ping RTT line
as each pong arrives and a summary at the end:

```sh
wsstat ping -c 5 wss://echo.example.com
```

```text
PING wss://echo.example.com (dns 5ms, tcp 10ms, tls 12ms, ws 7ms)
pong: seq=1 rtt=12.3ms
pong: seq=2 rtt=11.8ms
timeout: seq=3 (5s)
pong: seq=4 rtt=12.1ms
...
STATS wss://echo.example.com (5 sent, 4 received, 20.0% loss)
rtt: min=11.8ms avg=12.1ms max=12.3ms stddev=0.2ms
```

With no `-c` the run continues until you interrupt it (`Ctrl-C`) or the optional
`-w/--deadline` elapses, like `ping(8)`. `-o json` emits one `ping_reply` record
per ping and a final `ping_summary` (which stays the last record even on total
loss); `-q` prints the summary block only.

A missed pong (no reply within `--timeout`, default `5s`) is reported as a
`timeout` line and the run **continues**, exactly like `ping(8)` — so a transient
drop shows up as loss in the summary without ending the run. The run ends only at
`--count`, on `Ctrl-C`/`--deadline`, or when the connection actually closes. The
exit code is 0 when at least one pong was received and 1 on total loss or a dial
failure, so `wsstat ping -c 3 <url>` doubles as a liveness gate.

### Output

Output is split across three orthogonal axes:
Expand Down Expand Up @@ -257,6 +291,10 @@ rejected under `-o json|raw`.
| 2 | Usage error (bad flag or argument) |
| 130 | Interrupted; a second `Ctrl-C` forces teardown |

In `ping` mode exit 1 also covers total loss (zero pongs received), while partial
loss with at least one pong still exits 0, so `wsstat ping -c N <url>` works as a
liveness gate.

Usage errors (exit 2) always print plain text to stderr. Under `-o json`, a
runtime failure (exit 1) prints a `{"type":"error"}` envelope to stdout so a
`wsstat ... -o json | jq` pipeline stays parseable on the failure path:
Expand Down
49 changes: 40 additions & 9 deletions cmd/wsstat/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,34 +58,61 @@ type commonFlags struct {
version bool
}

// registerCommon registers the shared flags onto fs, binding them to c.
// newCommonFlags returns commonFlags with the resolved defaults pre-set, so a subcommand
// that skips a registration group still passes validation with the same defaults the
// flags would have carried. Registration binds flag defaults from these fields.
func newCommonFlags() commonFlags {
return commonFlags{output: "text", body: "auto", color: "auto", rpcVersion: "2.0"}
}

// registerCommon registers all shared flag groups onto fs, binding them to c. Subcommands
// that support only a subset (ping) call the group functions individually instead.
func registerCommon(fs *flag.FlagSet, c *commonFlags) {
fs.Var(&c.headers, "H", "HTTP header to include with the request (repeatable; format: \"Key: Value\")")
fs.Var(&c.headers, "header", "HTTP header to include with the request (repeatable; format: \"Key: Value\")")
fs.Var(&c.resolves, "resolve", "resolve host:port to address (repeatable; format: \"HOST:PORT:ADDRESS\")")
registerMessagingFlags(fs, c)
registerResponseFlags(fs, c)
registerOutputFlags(fs, c)
registerConnectionFlags(fs, c)
registerDiagnosticFlags(fs, c)
}

// registerMessagingFlags registers the outbound-payload flags (what gets sent).
func registerMessagingFlags(fs *flag.FlagSet, c *commonFlags) {
fs.StringVar(&c.rpcMethod, "rpc-method", "", "JSON-RPC method name to send (id=1, jsonrpc=2.0)")
fs.StringVar(&c.rpcVersion, "rpc-version", "2.0", "JSON-RPC version for --rpc-method: 2.0 or 1.0")
fs.StringVar(&c.rpcVersion, "rpc-version", c.rpcVersion, "JSON-RPC version for --rpc-method: 2.0 or 1.0")
fs.Var(&c.text, "t", "text message to send (repeatable in stream mode; @file or @- reads payload from a file or stdin)")
fs.Var(&c.text, "text", "text message to send (repeatable in stream mode; @file or @- reads payload from a file or stdin)")
}

fs.StringVar(&c.output, "o", "text", "output contract: text, json, or raw")
fs.StringVar(&c.output, "output", "text", "output contract: text, json, or raw")
// registerResponseFlags registers the response-payload flags (recording and rendering of
// what comes back). Ping mode has no response payloads and skips this group.
func registerResponseFlags(fs *flag.FlagSet, c *commonFlags) {
fs.StringVar(&c.file, "file", "",
"record response payloads to PATH as NDJSON, one per line (fails if PATH exists)")
fs.StringVar(&c.body, "body", "auto", "body rendering for text output: auto or compact")
fs.StringVar(&c.body, "body", c.body, "body rendering for text output: auto or compact")
fs.BoolVar(&c.clip, "clip", false, "clip each rendered line to terminal width (text output, TTY only)")
}

// registerOutputFlags registers the output-contract and verbosity flags.
func registerOutputFlags(fs *flag.FlagSet, c *commonFlags) {
fs.StringVar(&c.output, "o", c.output, "output contract: text, json, or raw")
fs.StringVar(&c.output, "output", c.output, "output contract: text, json, or raw")
fs.BoolVar(&c.showSecrets, "show-secrets", false,
"show sensitive header values in -vv output instead of masking them")
fs.BoolVar(&c.quiet, "q", false, "suppress all output except the response")
fs.BoolVar(&c.quiet, "quiet", false, "suppress all output except the response")
fs.BoolVar(&c.v1, "v", false, "increase verbosity (level 1)")
fs.BoolVar(&c.v1, "verbose", false, "increase verbosity (level 1)")
fs.BoolVar(&c.v2, "vv", false, "increase verbosity (level 2)")
fs.StringVar(&c.color, "color", c.color, "color output: auto, always, or never")
}

// registerConnectionFlags registers the dial- and transport-level flags.
func registerConnectionFlags(fs *flag.FlagSet, c *commonFlags) {
fs.Var(&c.headers, "H", "HTTP header to include with the request (repeatable; format: \"Key: Value\")")
fs.Var(&c.headers, "header", "HTTP header to include with the request (repeatable; format: \"Key: Value\")")
fs.Var(&c.resolves, "resolve", "resolve host:port to address (repeatable; format: \"HOST:PORT:ADDRESS\")")
fs.BoolVar(&c.insecure, "k", false, "skip TLS certificate verification")
fs.BoolVar(&c.insecure, "insecure", false, "skip TLS certificate verification")
fs.StringVar(&c.color, "color", "auto", "color output: auto, always, or never")
fs.DurationVar(&c.timeout, "timeout", 0, "read/dial timeout (e.g., 30s, 1m); 0 uses default (5s)")
fs.DurationVar(&c.closeTimeout, "close-timeout", 0,
"max wait for the peer's close echo before forcing teardown; 0 uses default (3s); capped at 5s")
Expand All @@ -95,6 +122,10 @@ func registerCommon(fs *flag.FlagSet, c *commonFlags) {
"WebSocket subprotocol(s) to negotiate, in preference order (comma-separated)")
fs.BoolVar(&c.validateUTF8, "validate-utf8", false,
"validate UTF-8 on inbound text frames and warn on violations (coder/websocket skips this)")
}

// registerDiagnosticFlags registers the diagnostics flags.
func registerDiagnosticFlags(fs *flag.FlagSet, c *commonFlags) {
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")
Expand Down
Loading